放开 DirectProject Codex 沙箱权限
Project CI / Repository checks (pull_request) Successful in 2m49s
Project CI / Frontend tests (pull_request) Successful in 3m27s
Project CI / Backend tests (pull_request) Successful in 7m8s
Project CI / Native shell tests (pull_request) Successful in 20m49s

DirectProject 使用 danger-full-access sandbox

移除 Codex 文件审批根白名单与路径提示约束

同步更新 AGC 技术方案和项目决策记录
This commit is contained in:
2026-09-14 12:49:58 +08:00
parent 28e4d6c627
commit 5ae8f35aaa
5 changed files with 79 additions and 250 deletions
@@ -1378,15 +1378,19 @@ fn codex_app_server_thread_start_params(
base_instructions: String,
use_model_provider: bool,
) -> serde_json::Value {
// DirectProject is an autonomous Codex session. The app-server sandbox
// remains the hard write boundary; approval prompts are not a second
// harness that can stall a turn. DirectHome/ToolHost stay passive.
// DirectProject is an autonomous Codex session with explicit full OS
// access; approval prompts are not a second harness that can stall a turn.
// DirectHome/ToolHost stay passive.
let approval_policy = "never";
let mut params = serde_json::json!({
"model": model,
"cwd": workspace_path,
"approvalPolicy": approval_policy,
"sandbox": if workspace_mode.allows_workspace_writes() { "workspace-write" } else { "read-only" },
"sandbox": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
"danger-full-access"
} else {
"read-only"
},
"ephemeral": true,
"baseInstructions": base_instructions
});
@@ -1404,7 +1408,6 @@ fn codex_app_server_turn_start_params(
thread_id: &str,
input: serde_json::Value,
model: &str,
workspace_path: &std::path::Path,
workspace_mode: CodexAppServerWorkspaceMode,
client_user_message_id: Option<&str>,
) -> serde_json::Value {
@@ -1415,14 +1418,12 @@ fn codex_app_server_turn_start_params(
"model": model,
"approvalPolicy": approval_policy,
});
if workspace_mode.allows_workspace_writes() {
// npm install/build must resolve project dependencies. Network access
// is enabled only for DirectProject; writableRoots keeps the file-write
// boundary at the real game workspace.
if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
// DirectProject is an explicitly user-selected local Codex session.
// Give the native Codex tools the full OS sandbox profile so they are
// not narrowed by a project-root writableRoots allowlist.
params["sandboxPolicy"] = serde_json::json!({
"type": "workspaceWrite",
"writableRoots": [workspace_path],
"networkAccess": true
"type": "dangerFullAccess"
});
}
if let Some(client_user_message_id) = client_user_message_id
@@ -1436,40 +1437,27 @@ fn codex_app_server_turn_start_params(
}
fn game_creator_codex_app_server_interaction_response(
workspace_path: &std::path::Path,
workspace_mode: CodexAppServerWorkspaceMode,
id: u64,
method: &str,
requested_grant_root: Option<&str>,
_method: &str,
_requested_grant_root: Option<&str>,
) -> serde_json::Value {
let direct_workspace = workspace_mode.allows_workspace_writes();
let file_change_within_workspace = game_creator_codex_file_change_request_is_allowed(
workspace_path,
method,
requested_grant_root,
);
if direct_workspace && file_change_within_workspace {
serde_json::json!({
if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
// Full-access DirectProject sessions do not use a file-root allowlist
// or a second approval gate. The declared sandbox policy is the only
// capability boundary for native Codex operations.
return serde_json::json!({
"id": id,
"result": { "decision": "accept" }
})
} else if direct_workspace && method == "item/fileChange/requestApproval" {
serde_json::json!({
"id": id,
"error": {
"code": -32602,
"message": "Genarrative AGC 只允许当前项目工作区的文件变更审批"
}
})
} else {
serde_json::json!({
"id": id,
"error": {
"code": -32601,
"message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求"
}
})
});
}
serde_json::json!({
"id": id,
"error": {
"code": -32601,
"message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求"
}
})
}
#[cfg(test)]
@@ -2734,7 +2722,6 @@ impl CodexAppServerConnection {
&thread_id,
input,
model,
&self.inner.workspace_path,
self.inner.workspace_mode,
direct_client_turn_id,
);
@@ -3293,7 +3280,6 @@ async fn read_game_creator_codex_app_server_stdout(
);
}
let response = game_creator_codex_app_server_interaction_response(
&inner.workspace_path,
inner.workspace_mode,
id,
method,
@@ -3635,82 +3621,6 @@ async fn read_game_creator_codex_app_server_stderr(
}
}
#[cfg(windows)]
fn game_creator_codex_workspace_path_key(path: &std::path::Path) -> String {
let value = path.to_string_lossy();
value
.strip_prefix("\\\\?\\")
.unwrap_or(value.as_ref())
.replace('/', "\\")
.trim_end_matches('\\')
.to_ascii_lowercase()
}
fn game_creator_codex_grant_root_is_within_workspace(
workspace: &std::path::Path,
grant_root: &str,
) -> bool {
fn canonicalize_with_missing_tail(path: &std::path::Path) -> Option<std::path::PathBuf> {
if !path.is_absolute()
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return None;
}
let mut existing = path.to_path_buf();
let mut missing_tail = Vec::new();
while !existing.exists() {
missing_tail.push(existing.file_name()?.to_os_string());
if !existing.pop() {
return None;
}
}
let mut normalized = existing.canonicalize().ok()?;
for component in missing_tail.iter().rev() {
normalized.push(component);
}
Some(normalized)
}
let workspace = workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf());
let Some(grant_root) = canonicalize_with_missing_tail(std::path::Path::new(grant_root)) else {
return false;
};
#[cfg(windows)]
{
let workspace_key = game_creator_codex_workspace_path_key(&workspace);
let grant_key = game_creator_codex_workspace_path_key(&grant_root);
grant_key == workspace_key
|| grant_key
.strip_prefix(&workspace_key)
.is_some_and(|suffix| suffix.starts_with('\\'))
}
#[cfg(not(windows))]
{
grant_root == workspace || grant_root.starts_with(&workspace)
}
}
fn game_creator_codex_file_change_request_is_allowed(
workspace: &std::path::Path,
method: &str,
grant_root: Option<&str>,
) -> bool {
if method != "item/fileChange/requestApproval" {
return false;
}
// `grantRoot: null` means the already-declared turn sandbox root. It is
// valid only for file changes; it must never authorize another capability
// or a broader permission request.
grant_root.is_none()
|| grant_root.is_some_and(|grant_root| {
game_creator_codex_grant_root_is_within_workspace(workspace, grant_root)
})
}
async fn fail_game_creator_codex_app_server_connection(
inner: &Weak<CodexAppServerInner>,
error: String,
@@ -4481,7 +4391,6 @@ mod tests {
"home-thread",
serde_json::json!([{ "type": "text", "text": "你好" }]),
"fixture-model",
workspace,
CodexAppServerWorkspaceMode::DirectHome,
None,
);
@@ -4495,7 +4404,6 @@ mod tests {
"item/permissions/requestApproval",
] {
let response = game_creator_codex_app_server_interaction_response(
workspace,
CodexAppServerWorkspaceMode::DirectHome,
7,
method,
@@ -4513,14 +4421,10 @@ mod tests {
}
#[test]
fn direct_project_protocol_and_interactions_expose_only_the_real_game_workspace() {
fn direct_project_protocol_uses_full_access_without_a_root_allowlist() {
let temp = tempfile::tempdir().expect("temp dir");
let project_root = temp.path().join("project");
let assets = project_root.join("assets");
let agent = project_root.join(".agent");
std::fs::create_dir_all(&project_root).expect("project root");
std::fs::create_dir(&assets).expect("assets directory");
std::fs::create_dir(&agent).expect("agent directory");
let workspace =
resolve_direct_codex_game_workspace(&project_root).expect("resolve project workspace");
assert_eq!(
@@ -4536,83 +4440,40 @@ mod tests {
true,
);
assert_eq!(thread["cwd"], serde_json::json!(workspace));
assert_eq!(thread["sandbox"], "workspace-write");
assert_eq!(thread["sandbox"], "danger-full-access");
let turn = codex_app_server_turn_start_params(
"project-thread",
serde_json::json!([{ "type": "text", "text": "修复游戏" }]),
"fixture-model",
&workspace,
CodexAppServerWorkspaceMode::DirectProject,
Some("direct-turn-0001"),
);
assert_eq!(turn["clientUserMessageId"], "direct-turn-0001");
assert_eq!(
turn.pointer("/sandboxPolicy/writableRoots/0"),
Some(&serde_json::json!(workspace))
);
assert_eq!(
turn.pointer("/sandboxPolicy/networkAccess"),
Some(&serde_json::json!(true))
);
let authority_paths = [
turn.get("cwd"),
turn.pointer("/sandboxPolicy/writableRoots/0"),
];
assert!(
authority_paths
.iter()
.flatten()
.all(|value| value.as_str() == Some(workspace.to_string_lossy().as_ref())),
"writable params must be exactly the project workspace"
turn.pointer("/sandboxPolicy/type"),
Some(&serde_json::json!("dangerFullAccess"))
);
assert!(turn.pointer("/sandboxPolicy/writableRoots").is_none());
assert!(turn.pointer("/sandboxPolicy/networkAccess").is_none());
let workspace_string = workspace.to_string_lossy().into_owned();
for allowed_root in [None, Some(workspace_string.as_str())] {
for (id, method) in [
(9, "item/fileChange/requestApproval"),
(10, "item/commandExecution/requestApproval"),
(11, "item/permissions/requestApproval"),
(12, "item/tool/call"),
] {
let response = game_creator_codex_app_server_interaction_response(
&workspace,
CodexAppServerWorkspaceMode::DirectProject,
9,
"item/fileChange/requestApproval",
allowed_root,
id,
method,
Some("C:\\outside-project"),
);
assert_eq!(
response.pointer("/result/decision"),
Some(&serde_json::json!("accept"))
);
}
for forbidden_root in [assets, agent] {
let forbidden_root = forbidden_root.to_string_lossy().into_owned();
let response = game_creator_codex_app_server_interaction_response(
&workspace,
CodexAppServerWorkspaceMode::DirectProject,
10,
"item/fileChange/requestApproval",
Some(&forbidden_root),
);
assert_eq!(
response.pointer("/result/decision"),
Some(&serde_json::json!("accept")),
"project-root children must stay writable: {forbidden_root}"
);
}
for method in [
"item/commandExecution/requestApproval",
"item/permissions/requestApproval",
"item/tool/call",
] {
for requested_root in [None, Some(workspace_string.as_str())] {
let response = game_creator_codex_app_server_interaction_response(
&workspace,
CodexAppServerWorkspaceMode::DirectProject,
11,
method,
requested_root,
);
assert!(response.get("error").is_some());
assert!(response.get("result").is_none());
}
}
}
#[test]
@@ -4630,26 +4491,6 @@ mod tests {
assert!(resolve_direct_codex_game_workspace(&file_root).is_err());
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn direct_project_grant_root_comparison_remains_case_sensitive() {
let temp = tempfile::tempdir().expect("temp dir");
let project_root = temp.path().join("Project");
let child = project_root.join("assets");
let different_case = temp.path().join("project").join("assets");
std::fs::create_dir_all(&child).expect("child directory");
std::fs::create_dir_all(&different_case).expect("different-case directory");
assert!(game_creator_codex_grant_root_is_within_workspace(
&project_root,
project_root.to_string_lossy().as_ref()
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&project_root,
different_case.to_string_lossy().as_ref()
));
}
#[cfg(unix)]
#[test]
fn direct_project_rejects_a_non_directory_workspace() {
@@ -5246,51 +5087,25 @@ while IFS= read -r line; do :; done
}
#[test]
fn direct_file_change_approval_is_limited_to_workspace() {
let temp = tempfile::tempdir().expect("temp dir");
let workspace = temp.path().join("demo");
let child = workspace.join("assets");
let sibling = temp.path().join("demolition");
std::fs::create_dir_all(&child).expect("workspace child");
std::fs::create_dir(&sibling).expect("sibling");
assert!(game_creator_codex_file_change_request_is_allowed(
&workspace,
fn direct_project_interactions_accept_full_access_without_a_root_allowlist() {
for method in [
"item/fileChange/requestApproval",
None
));
assert!(game_creator_codex_grant_root_is_within_workspace(
&workspace,
workspace.to_string_lossy().as_ref()
));
assert!(game_creator_codex_grant_root_is_within_workspace(
&workspace,
child.to_string_lossy().as_ref()
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&workspace,
sibling.to_string_lossy().as_ref()
));
assert!(!game_creator_codex_file_change_request_is_allowed(
&workspace,
"item/fileChange/requestApproval",
Some(sibling.to_string_lossy().as_ref())
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&workspace,
sibling.join("missing").to_string_lossy().as_ref()
));
assert!(game_creator_codex_grant_root_is_within_workspace(
&workspace,
workspace.join("missing").to_string_lossy().as_ref()
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&workspace,
workspace
.join("..")
.join("outside")
.to_string_lossy()
.as_ref()
));
"item/commandExecution/requestApproval",
"item/permissions/requestApproval",
"item/tool/call",
] {
let response = game_creator_codex_app_server_interaction_response(
CodexAppServerWorkspaceMode::DirectProject,
1,
method,
Some("C:\\outside-project"),
);
assert_eq!(
response.pointer("/result/decision"),
Some(&serde_json::json!("accept"))
);
assert!(response.get("error").is_none());
}
}
#[test]
File diff suppressed because one or more lines are too long