mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
feat: snapshot dependency check prototype
This commit is contained in:
Generated
+18
@@ -2640,6 +2640,7 @@ dependencies = [
|
||||
"codex-context-fragments",
|
||||
"codex-core-plugins",
|
||||
"codex-core-skills",
|
||||
"codex-dependency-check",
|
||||
"codex-exec-server",
|
||||
"codex-execpolicy",
|
||||
"codex-extension-api",
|
||||
@@ -2843,6 +2844,23 @@ dependencies = [
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-dependency-check"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-client",
|
||||
"codex-shell-command",
|
||||
"pretty_assertions",
|
||||
"reqwest 0.12.28",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-exec"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -36,6 +36,7 @@ members = [
|
||||
"shell-command",
|
||||
"shell-escalation",
|
||||
"skills",
|
||||
"dependency-check",
|
||||
"core",
|
||||
"core-api",
|
||||
"core-plugins",
|
||||
@@ -175,6 +176,7 @@ codex-core = { path = "core" }
|
||||
codex-core-api = { path = "core-api" }
|
||||
codex-core-plugins = { path = "core-plugins" }
|
||||
codex-core-skills = { path = "core-skills" }
|
||||
codex-dependency-check = { path = "dependency-check" }
|
||||
codex-exec = { path = "exec" }
|
||||
codex-file-system = { path = "file-system" }
|
||||
codex-exec-server-protocol = { path = "exec-server-protocol" }
|
||||
|
||||
@@ -35,6 +35,7 @@ codex-context-fragments = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-core-plugins = { workspace = true }
|
||||
codex-core-skills = { workspace = true }
|
||||
codex-dependency-check = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-extension-api = { workspace = true }
|
||||
codex-features = { workspace = true }
|
||||
|
||||
@@ -23,6 +23,8 @@ use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::handlers::apply_granted_turn_permissions;
|
||||
use crate::tools::handlers::apply_patch_spec::create_apply_patch_freeform_tool;
|
||||
use crate::tools::handlers::dependency_guard::path_uri_is_dependency_manifest;
|
||||
use crate::tools::handlers::dependency_manifest_edit_message;
|
||||
use crate::tools::handlers::resolve_tool_environment;
|
||||
use crate::tools::handlers::updated_hook_command;
|
||||
use crate::tools::hook_names::HookToolName;
|
||||
@@ -218,6 +220,12 @@ fn file_paths_for_action(action: &ApplyPatchAction) -> Vec<PathUri> {
|
||||
keys
|
||||
}
|
||||
|
||||
fn action_touches_dependency_manifest(action: &ApplyPatchAction) -> bool {
|
||||
file_paths_for_action(action)
|
||||
.iter()
|
||||
.any(path_uri_is_dependency_manifest)
|
||||
}
|
||||
|
||||
fn write_permissions_for_paths(
|
||||
file_paths: &[AbsolutePathBuf],
|
||||
file_system_sandbox_policy: &codex_protocol::permissions::FileSystemSandboxPolicy,
|
||||
@@ -396,6 +404,13 @@ impl ApplyPatchHandler {
|
||||
.await
|
||||
{
|
||||
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
|
||||
if session.features().enabled(Feature::DependencyCheck)
|
||||
&& action_touches_dependency_manifest(&changes)
|
||||
{
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
dependency_manifest_edit_message(),
|
||||
));
|
||||
}
|
||||
let (file_paths, effective_additional_permissions, file_system_sandbox_policy) =
|
||||
effective_patch_permissions(
|
||||
session.as_ref(),
|
||||
@@ -559,6 +574,13 @@ pub(crate) async fn intercept_apply_patch(
|
||||
.await
|
||||
{
|
||||
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
|
||||
if session.features().enabled(Feature::DependencyCheck)
|
||||
&& action_touches_dependency_manifest(&changes)
|
||||
{
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
dependency_manifest_edit_message(),
|
||||
));
|
||||
}
|
||||
let (approval_keys, effective_additional_permissions, file_system_sandbox_policy) =
|
||||
effective_patch_permissions(
|
||||
session.as_ref(),
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
use crate::exec_env::create_env;
|
||||
use crate::exec_policy::ExecApprovalRequest;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::session::turn_context::TurnEnvironment;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::context::boxed_tool_output;
|
||||
use crate::tools::handlers::dependency_check_output::blocked_output;
|
||||
use crate::tools::handlers::dependency_check_output::command_failure_message;
|
||||
use crate::tools::handlers::dependency_check_output::command_failure_output;
|
||||
use crate::tools::handlers::dependency_check_output::format_blocked_policy;
|
||||
use crate::tools::handlers::dependency_check_output::format_success;
|
||||
use crate::tools::handlers::dependency_check_output::graph_mismatch_output;
|
||||
use crate::tools::handlers::dependency_check_output::installed_graph_mismatch_output;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::handlers::resolve_tool_environment;
|
||||
use crate::tools::orchestrator::ToolOrchestrator;
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
use crate::tools::runtimes::shell::ShellRequest;
|
||||
use crate::tools::runtimes::shell::ShellRuntime;
|
||||
use crate::tools::runtimes::shell::ShellRuntimeBackend;
|
||||
use crate::tools::sandboxing::ApprovalReviewMode;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
use codex_dependency_check::DependencyCheckRequest;
|
||||
use codex_dependency_check::DependencyInstallCommand;
|
||||
use codex_dependency_check::DependencyPolicyAction;
|
||||
use codex_dependency_check::NpmGraph;
|
||||
use codex_dependency_check::NpmInstalledGraph;
|
||||
use codex_dependency_check::OsvClient;
|
||||
use codex_dependency_check::detect_dependency_install_command;
|
||||
use codex_dependency_check::npm_ci_command;
|
||||
use codex_dependency_check::npm_install_command;
|
||||
use codex_dependency_check::npm_query_installed_command;
|
||||
use codex_dependency_check::npm_query_lock_command;
|
||||
use codex_dependency_check::npm_rebuild_command;
|
||||
use codex_dependency_check::validate_npm_manifest;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::SandboxPermissions;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_tools::create_dependency_check_tool;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub struct DependencyCheckHandler;
|
||||
|
||||
const UNSUPPORTED_LOCKFILES: &[&str] = &[
|
||||
"npm-shrinkwrap.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"bun.lock",
|
||||
"bun.lockb",
|
||||
];
|
||||
|
||||
impl ToolExecutor<ToolInvocation> for DependencyCheckHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("dependency_check")
|
||||
}
|
||||
|
||||
fn spec(&self) -> ToolSpec {
|
||||
create_dependency_check_tool()
|
||||
}
|
||||
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(async move { self.handle_call(invocation).await.map(boxed_tool_output) })
|
||||
}
|
||||
}
|
||||
|
||||
impl DependencyCheckHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<FunctionToolOutput, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
step_context,
|
||||
cancellation_token,
|
||||
call_id,
|
||||
tool_name,
|
||||
payload,
|
||||
..
|
||||
} = invocation;
|
||||
let ToolPayload::Function { arguments } = payload else {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"dependency_check received an unsupported payload".to_string(),
|
||||
));
|
||||
};
|
||||
let request: DependencyCheckRequest = parse_arguments(&arguments)?;
|
||||
request
|
||||
.validate()
|
||||
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?;
|
||||
let Some(turn_environment) =
|
||||
resolve_tool_environment(&step_context.environments, /*environment_id*/ None)?
|
||||
else {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"dependency_check requires a primary environment".to_string(),
|
||||
));
|
||||
};
|
||||
let workdir = request
|
||||
.workdir
|
||||
.as_deref()
|
||||
.filter(|workdir| !workdir.is_empty())
|
||||
.map_or_else(
|
||||
|| Ok(turn_environment.cwd().clone()),
|
||||
|workdir| turn_environment.cwd().join(workdir),
|
||||
)
|
||||
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?;
|
||||
let workdir = workdir
|
||||
.to_abs_path()
|
||||
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?;
|
||||
|
||||
let manifest_path = workdir.join("package.json");
|
||||
let manifest = read_regular_file(&manifest_path).await.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"dependency_check could not read {}: {err}",
|
||||
manifest_path.display()
|
||||
))
|
||||
})?;
|
||||
validate_npm_manifest(&manifest).map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"dependency_check cannot use this project: {err}"
|
||||
))
|
||||
})?;
|
||||
if let Some(lockfile) = first_existing_file(&workdir, UNSUPPORTED_LOCKFILES).await {
|
||||
return Ok(blocked_output(format!(
|
||||
"Dependency Check did not modify the project because `{lockfile}` is not supported by the npm-first implementation."
|
||||
)));
|
||||
}
|
||||
|
||||
let resolution_dir = tempfile::Builder::new()
|
||||
.prefix("codex-dependency-check-")
|
||||
.tempdir()
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"dependency_check could not create its temporary resolution directory: {err}"
|
||||
))
|
||||
})?;
|
||||
copy_resolution_inputs(&workdir, resolution_dir.path()).await?;
|
||||
|
||||
let runner = DependencyCommandRunner {
|
||||
session: session.clone(),
|
||||
turn: turn.clone(),
|
||||
turn_environment: turn_environment.clone(),
|
||||
cancellation_token,
|
||||
call_id: call_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
};
|
||||
let resolve = runner
|
||||
.run(
|
||||
npm_install_command(Some(&request), /*package_lock_only*/ true),
|
||||
resolution_dir.path(),
|
||||
ScriptPolicy::Disabled,
|
||||
WorkingDirectoryAccess::PreapprovedScratch,
|
||||
"Resolve the npm dependency graph with lifecycle scripts disabled.",
|
||||
)
|
||||
.await?;
|
||||
if resolve.exit_code != 0 {
|
||||
return Ok(command_failure_output(
|
||||
"temporary graph resolution",
|
||||
&resolve,
|
||||
));
|
||||
}
|
||||
let checked_graph = match query_graph(
|
||||
&runner,
|
||||
resolution_dir.path(),
|
||||
WorkingDirectoryAccess::PreapprovedScratch,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(graph) => graph,
|
||||
Err(message) => return Ok(blocked_output(message)),
|
||||
};
|
||||
|
||||
let policy = match OsvClient::new() {
|
||||
Ok(client) => match client.evaluate(&checked_graph).await {
|
||||
Ok(report) => report,
|
||||
Err(err) => {
|
||||
return Ok(blocked_output(format!(
|
||||
"Dependency Check stopped before modifying the project because OSV did not return complete evidence: {err}"
|
||||
)));
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
return Ok(blocked_output(format!(
|
||||
"Dependency Check stopped before modifying the project because the OSV client could not start: {err}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if policy.action == DependencyPolicyAction::Block {
|
||||
return Ok(blocked_output(format_blocked_policy(&policy)));
|
||||
}
|
||||
|
||||
let lock_update = runner
|
||||
.run(
|
||||
npm_install_command(Some(&request), /*package_lock_only*/ true),
|
||||
&workdir,
|
||||
ScriptPolicy::Disabled,
|
||||
WorkingDirectoryAccess::ProjectDependencyFiles,
|
||||
"Update package.json and package-lock.json only after the resolved graph passed dependency policy.",
|
||||
)
|
||||
.await?;
|
||||
if lock_update.exit_code != 0 {
|
||||
return Ok(command_failure_output("project lock update", &lock_update));
|
||||
}
|
||||
let locked_graph =
|
||||
match query_graph(&runner, &workdir, WorkingDirectoryAccess::Default).await? {
|
||||
Ok(graph) => graph,
|
||||
Err(message) => return Ok(blocked_output(message)),
|
||||
};
|
||||
if let Err(mismatch) = checked_graph.compare(&locked_graph) {
|
||||
return Ok(graph_mismatch_output("project lock update", &mismatch));
|
||||
}
|
||||
|
||||
let install = runner
|
||||
.run(
|
||||
npm_ci_command(),
|
||||
&workdir,
|
||||
ScriptPolicy::Disabled,
|
||||
WorkingDirectoryAccess::Default,
|
||||
"Cleanly install the already-checked npm graph while lifecycle scripts remain disabled.",
|
||||
)
|
||||
.await?;
|
||||
if install.exit_code != 0 {
|
||||
return Ok(command_failure_output("script-disabled install", &install));
|
||||
}
|
||||
let installed_graph = match query_installed_graph(&runner, &workdir).await? {
|
||||
Ok(graph) => graph,
|
||||
Err(message) => return Ok(blocked_output(message)),
|
||||
};
|
||||
if let Err(mismatch) = checked_graph.compare_installed(&installed_graph) {
|
||||
return Ok(installed_graph_mismatch_output(&mismatch));
|
||||
}
|
||||
|
||||
let rebuild = runner
|
||||
.run(
|
||||
npm_rebuild_command(),
|
||||
&workdir,
|
||||
ScriptPolicy::Enabled,
|
||||
WorkingDirectoryAccess::Default,
|
||||
"Run npm lifecycle scripts only after the installed lock graph matches the graph checked by OSV.",
|
||||
)
|
||||
.await?;
|
||||
if rebuild.exit_code != 0 {
|
||||
return Ok(command_failure_output("npm rebuild", &rebuild));
|
||||
}
|
||||
|
||||
Ok(FunctionToolOutput::from_text(
|
||||
format_success(&request, &policy, checked_graph.packages().len()),
|
||||
Some(true),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreToolRuntime for DependencyCheckHandler {}
|
||||
|
||||
pub(crate) fn dependency_install_redirect(command: &[String]) -> Option<String> {
|
||||
let DependencyInstallCommand { package_manager } = detect_dependency_install_command(command)?;
|
||||
Some(format!(
|
||||
"Dependency Check is enabled. Do not add dependencies with a raw `{}` command. Use the `dependency_check` tool with exact package names and versions so Codex can resolve and inspect the complete graph before lifecycle scripts run. The first implementation supports npm projects only.",
|
||||
package_manager.command_name()
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn dependency_manifest_edit_message() -> String {
|
||||
"Dependency Check is enabled. Do not edit JavaScript dependency manifests or lockfiles directly. Use the `dependency_check` tool with exact npm package names and versions so the generated graph is checked before lifecycle scripts run. The first implementation supports npm projects only."
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn query_graph(
|
||||
runner: &DependencyCommandRunner,
|
||||
cwd: &Path,
|
||||
working_directory_access: WorkingDirectoryAccess,
|
||||
) -> Result<Result<NpmGraph, String>, FunctionCallError> {
|
||||
let query = runner
|
||||
.run(
|
||||
npm_query_lock_command(),
|
||||
cwd,
|
||||
ScriptPolicy::Disabled,
|
||||
working_directory_access,
|
||||
"Read the exact npm lock graph for dependency verification.",
|
||||
)
|
||||
.await?;
|
||||
if query.exit_code != 0 {
|
||||
return Ok(Err(command_failure_message("npm graph query", &query)));
|
||||
}
|
||||
Ok(NpmGraph::from_query_json(&query.stdout.text)
|
||||
.map_err(|err| format!("Dependency Check stopped before lifecycle scripts because npm returned an unverifiable graph: {err}")))
|
||||
}
|
||||
|
||||
async fn query_installed_graph(
|
||||
runner: &DependencyCommandRunner,
|
||||
cwd: &Path,
|
||||
) -> Result<Result<NpmInstalledGraph, String>, FunctionCallError> {
|
||||
let query = runner
|
||||
.run(
|
||||
npm_query_installed_command(),
|
||||
cwd,
|
||||
ScriptPolicy::Disabled,
|
||||
WorkingDirectoryAccess::Default,
|
||||
"Read the installed npm artifact graph for dependency verification.",
|
||||
)
|
||||
.await?;
|
||||
if query.exit_code != 0 {
|
||||
return Ok(Err(command_failure_message(
|
||||
"installed npm graph query",
|
||||
&query,
|
||||
)));
|
||||
}
|
||||
Ok(NpmInstalledGraph::from_query_json(&query.stdout.text).map_err(|err| {
|
||||
format!(
|
||||
"Dependency Check stopped before lifecycle scripts because npm returned an unverifiable installed graph: {err}"
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
struct DependencyCommandRunner {
|
||||
session: Arc<Session>,
|
||||
turn: Arc<TurnContext>,
|
||||
turn_environment: TurnEnvironment,
|
||||
cancellation_token: CancellationToken,
|
||||
call_id: String,
|
||||
tool_name: ToolName,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ScriptPolicy {
|
||||
Disabled,
|
||||
Enabled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum WorkingDirectoryAccess {
|
||||
Default,
|
||||
PreapprovedScratch,
|
||||
ProjectDependencyFiles,
|
||||
}
|
||||
|
||||
struct CommandPermissions {
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
additional_permissions: Option<AdditionalPermissionProfile>,
|
||||
#[cfg(unix)]
|
||||
additional_permissions_preapproved: bool,
|
||||
}
|
||||
|
||||
impl DependencyCommandRunner {
|
||||
async fn run(
|
||||
&self,
|
||||
command: Vec<String>,
|
||||
cwd: &Path,
|
||||
script_policy: ScriptPolicy,
|
||||
working_directory_access: WorkingDirectoryAccess,
|
||||
justification: &str,
|
||||
) -> Result<ExecToolCallOutput, FunctionCallError> {
|
||||
let mut env = create_env(
|
||||
&self.turn.config.permissions.shell_environment_policy,
|
||||
Some(self.session.thread_id),
|
||||
);
|
||||
let ignore_scripts = if matches!(script_policy, ScriptPolicy::Disabled) {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
};
|
||||
env.insert(
|
||||
"NPM_CONFIG_IGNORE_SCRIPTS".to_string(),
|
||||
ignore_scripts.to_string(),
|
||||
);
|
||||
let mut explicit_env_overrides = self
|
||||
.turn
|
||||
.config
|
||||
.permissions
|
||||
.shell_environment_policy
|
||||
.r#set
|
||||
.clone();
|
||||
explicit_env_overrides.insert(
|
||||
"NPM_CONFIG_IGNORE_SCRIPTS".to_string(),
|
||||
ignore_scripts.to_string(),
|
||||
);
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(cwd).map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"dependency_check command paths must be absolute: {err}"
|
||||
))
|
||||
})?;
|
||||
let command_permissions = command_permissions(cwd.as_path(), working_directory_access)?;
|
||||
#[cfg(unix)]
|
||||
let approval_sandbox_permissions = if command_permissions.additional_permissions_preapproved
|
||||
{
|
||||
SandboxPermissions::UseDefault
|
||||
} else {
|
||||
command_permissions.sandbox_permissions
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let approval_sandbox_permissions = command_permissions.sandbox_permissions;
|
||||
let exec_approval_requirement = self
|
||||
.session
|
||||
.services
|
||||
.exec_policy
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
approval_policy: self.turn.approval_policy.value(),
|
||||
permission_profile: self.turn.permission_profile(),
|
||||
windows_sandbox_level: self.turn.windows_sandbox_level,
|
||||
sandbox_permissions: approval_sandbox_permissions,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
let hook_command = codex_shell_command::parse_command::shlex_join(&command);
|
||||
let request = ShellRequest {
|
||||
command,
|
||||
turn_environment: self.turn_environment.clone(),
|
||||
shell_type: None,
|
||||
hook_command,
|
||||
cwd,
|
||||
timeout_ms: Some(120_000),
|
||||
cancellation_token: self.cancellation_token.clone(),
|
||||
env,
|
||||
explicit_env_overrides,
|
||||
network: self.turn.network.clone(),
|
||||
sandbox_permissions: command_permissions.sandbox_permissions,
|
||||
additional_permissions: command_permissions.additional_permissions,
|
||||
#[cfg(unix)]
|
||||
additional_permissions_preapproved: command_permissions
|
||||
.additional_permissions_preapproved,
|
||||
justification: Some(justification.to_string()),
|
||||
exec_approval_requirement,
|
||||
approval_review_mode: match working_directory_access {
|
||||
WorkingDirectoryAccess::ProjectDependencyFiles => ApprovalReviewMode::User,
|
||||
WorkingDirectoryAccess::Default
|
||||
| WorkingDirectoryAccess::PreapprovedScratch => ApprovalReviewMode::Configured,
|
||||
},
|
||||
};
|
||||
let tool_ctx = ToolCtx {
|
||||
session: self.session.clone(),
|
||||
turn: self.turn.clone(),
|
||||
call_id: self.call_id.clone(),
|
||||
tool_name: self.tool_name.clone(),
|
||||
};
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
let mut runtime =
|
||||
ShellRuntime::for_shell_command(ShellRuntimeBackend::ShellCommandClassic);
|
||||
orchestrator
|
||||
.run(
|
||||
&mut runtime,
|
||||
&request,
|
||||
&tool_ctx,
|
||||
self.turn.as_ref(),
|
||||
self.turn.approval_policy.value(),
|
||||
)
|
||||
.await
|
||||
.map(|result| result.output)
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"dependency_check command execution failed: {err:?}"
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn command_permissions(
|
||||
cwd: &Path,
|
||||
working_directory_access: WorkingDirectoryAccess,
|
||||
) -> Result<CommandPermissions, FunctionCallError> {
|
||||
let (write_paths, _additional_permissions_preapproved) = match working_directory_access {
|
||||
WorkingDirectoryAccess::Default => {
|
||||
return Ok(CommandPermissions {
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
#[cfg(unix)]
|
||||
additional_permissions_preapproved: false,
|
||||
});
|
||||
}
|
||||
WorkingDirectoryAccess::PreapprovedScratch => {
|
||||
#[cfg(not(unix))]
|
||||
return Ok(CommandPermissions {
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
});
|
||||
|
||||
#[cfg(unix)]
|
||||
(vec![cwd.to_path_buf()], true)
|
||||
}
|
||||
WorkingDirectoryAccess::ProjectDependencyFiles => (
|
||||
vec![cwd.join("package.json"), cwd.join("package-lock.json")],
|
||||
false,
|
||||
),
|
||||
};
|
||||
let write_paths = write_paths
|
||||
.into_iter()
|
||||
.map(AbsolutePathBuf::from_absolute_path)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"dependency_check command paths must be absolute: {err}"
|
||||
))
|
||||
})?;
|
||||
Ok(CommandPermissions {
|
||||
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
|
||||
additional_permissions: Some(AdditionalPermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
entries: write_paths
|
||||
.into_iter()
|
||||
.map(|path| FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path },
|
||||
access: FileSystemAccessMode::Write,
|
||||
})
|
||||
.collect(),
|
||||
glob_scan_max_depth: None,
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
#[cfg(unix)]
|
||||
additional_permissions_preapproved: _additional_permissions_preapproved,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_regular_file(path: &Path) -> std::io::Result<String> {
|
||||
let metadata = tokio::fs::symlink_metadata(path).await?;
|
||||
if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
|
||||
return Err(std::io::Error::other("expected a regular file"));
|
||||
}
|
||||
tokio::fs::read_to_string(path).await
|
||||
}
|
||||
|
||||
async fn first_existing_file(cwd: &Path, names: &[&str]) -> Option<String> {
|
||||
for name in names {
|
||||
if tokio::fs::symlink_metadata(cwd.join(name)).await.is_ok() {
|
||||
return Some((*name).to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn copy_resolution_inputs(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
for name in ["package.json", "package-lock.json", ".npmrc"] {
|
||||
let source_path = source.join(name);
|
||||
let metadata = match tokio::fs::symlink_metadata(&source_path).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(err) => {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"dependency_check could not inspect {}: {err}",
|
||||
source_path.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"dependency_check requires {} to be a regular file",
|
||||
source_path.display()
|
||||
)));
|
||||
}
|
||||
tokio::fs::copy(&source_path, destination.join(name))
|
||||
.await
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"dependency_check could not copy {} into its temporary resolution directory: {err}",
|
||||
source_path.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn raw_install_redirect_names_package_manager() {
|
||||
let message = dependency_install_redirect(&[
|
||||
"/bin/zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"pnpm add zod@3.23.8".to_string(),
|
||||
])
|
||||
.expect("redirect");
|
||||
assert!(message.contains("raw `pnpm` command"));
|
||||
assert!(message.contains("dependency_check"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn scratch_commands_receive_preapproved_directory_access() {
|
||||
let scratch = tempfile::tempdir().expect("scratch directory");
|
||||
let permissions =
|
||||
command_permissions(scratch.path(), WorkingDirectoryAccess::PreapprovedScratch)
|
||||
.expect("command permissions");
|
||||
|
||||
assert_eq!(
|
||||
permissions.sandbox_permissions,
|
||||
SandboxPermissions::WithAdditionalPermissions
|
||||
);
|
||||
assert!(permissions.additional_permissions_preapproved);
|
||||
assert_eq!(
|
||||
permissions.additional_permissions,
|
||||
Some(PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: None,
|
||||
write: Some(vec![
|
||||
AbsolutePathBuf::from_absolute_path(scratch.path())
|
||||
.expect("absolute scratch path")
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_lock_update_requests_dependency_file_access() {
|
||||
let project = tempfile::tempdir().expect("project directory");
|
||||
let permissions = command_permissions(
|
||||
project.path(),
|
||||
WorkingDirectoryAccess::ProjectDependencyFiles,
|
||||
)
|
||||
.expect("command permissions");
|
||||
|
||||
assert_eq!(
|
||||
permissions.sandbox_permissions,
|
||||
SandboxPermissions::WithAdditionalPermissions
|
||||
);
|
||||
#[cfg(unix)]
|
||||
assert!(!permissions.additional_permissions_preapproved);
|
||||
assert_eq!(
|
||||
permissions.additional_permissions,
|
||||
Some(PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: None,
|
||||
write: Some(vec![
|
||||
AbsolutePathBuf::from_absolute_path(project.path().join("package.json"))
|
||||
.expect("absolute package.json path"),
|
||||
AbsolutePathBuf::from_absolute_path(
|
||||
project.path().join("package-lock.json")
|
||||
)
|
||||
.expect("absolute package-lock.json path"),
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use codex_dependency_check::DependencyCheckRequest;
|
||||
use codex_dependency_check::DependencyPolicyAction;
|
||||
use codex_dependency_check::DependencyPolicyReport;
|
||||
use codex_dependency_check::DependencyRisk;
|
||||
use codex_dependency_check::DependencyRiskKind;
|
||||
use codex_dependency_check::NpmGraphMismatch;
|
||||
use codex_dependency_check::NpmInstalledGraphMismatch;
|
||||
|
||||
pub(super) fn blocked_output(message: String) -> FunctionToolOutput {
|
||||
FunctionToolOutput::from_text(message, Some(false))
|
||||
}
|
||||
|
||||
pub(super) fn command_failure_output(
|
||||
stage: &str,
|
||||
output: &ExecToolCallOutput,
|
||||
) -> FunctionToolOutput {
|
||||
blocked_output(command_failure_message(stage, output))
|
||||
}
|
||||
|
||||
pub(super) fn command_failure_message(stage: &str, output: &ExecToolCallOutput) -> String {
|
||||
format!(
|
||||
"Dependency Check stopped during {stage} with exit code {}. Lifecycle scripts were not enabled.\n{}",
|
||||
output.exit_code,
|
||||
output.aggregated_output.text.trim()
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn graph_mismatch_output(
|
||||
stage: &str,
|
||||
mismatch: &NpmGraphMismatch,
|
||||
) -> FunctionToolOutput {
|
||||
blocked_output(format!(
|
||||
"Dependency Check stopped after {stage} and before lifecycle scripts because npm produced a different graph than the one checked by OSV: {mismatch}."
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn installed_graph_mismatch_output(
|
||||
mismatch: &NpmInstalledGraphMismatch,
|
||||
) -> FunctionToolOutput {
|
||||
blocked_output(format!(
|
||||
"Dependency Check stopped after the script-disabled install and before lifecycle scripts because npm installed a different artifact graph than the one checked by OSV: {mismatch}."
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn format_blocked_policy(report: &DependencyPolicyReport) -> String {
|
||||
format!(
|
||||
"Dependency Check blocked before modifying the project or running package code. OSV reported malware in the resolved graph:\n{}",
|
||||
format_risks(&report.risks)
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn format_success(
|
||||
request: &DependencyCheckRequest,
|
||||
report: &DependencyPolicyReport,
|
||||
graph_packages: usize,
|
||||
) -> String {
|
||||
let requested = request
|
||||
.dependencies
|
||||
.iter()
|
||||
.map(codex_dependency_check::DependencySpec::npm_specifier)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let warnings = if report.action == DependencyPolicyAction::Warn {
|
||||
format!(
|
||||
" OSV reported non-malware advisories that did not block the install:\n{}",
|
||||
format_risks(&report.risks)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"Dependency Check installed {requested}. It checked {} unique package coordinates across {graph_packages} resolved artifacts, verified the project lock graph, matched the clean script-disabled install to the checked artifact graph, and completed npm rebuild.{warnings}",
|
||||
report.checked_packages
|
||||
)
|
||||
}
|
||||
|
||||
fn format_risks(risks: &[DependencyRisk]) -> String {
|
||||
let mut lines = risks
|
||||
.iter()
|
||||
.take(20)
|
||||
.map(|risk| {
|
||||
let kind = match risk.kind {
|
||||
DependencyRiskKind::Malware => "malware",
|
||||
DependencyRiskKind::Vulnerability => "vulnerability",
|
||||
};
|
||||
match &risk.summary {
|
||||
Some(summary) => format!(
|
||||
"- npm:{}@{} | {} | {}: {}",
|
||||
risk.package_name, risk.package_version, risk.advisory_id, kind, summary
|
||||
),
|
||||
None => format!(
|
||||
"- npm:{}@{} | {} | {}",
|
||||
risk.package_name, risk.package_version, risk.advisory_id, kind
|
||||
),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if risks.len() > lines.len() {
|
||||
lines.push(format!(
|
||||
"- ... and {} more advisories",
|
||||
risks.len() - lines.len()
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use codex_dependency_check::is_dependency_manifest_path;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::models::SandboxPermissions;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
|
||||
pub(crate) fn dependency_permission_request_message() -> String {
|
||||
"Dependency Check is enabled. Generic tools cannot request permissions that would make dependency manifests writable or bypass their sandbox protection. Use the `dependency_check` tool for npm dependency updates."
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn dependency_permissions_overlap_project(
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
additional_permissions: Option<&AdditionalPermissionProfile>,
|
||||
project_root: &AbsolutePathBuf,
|
||||
) -> bool {
|
||||
if sandbox_permissions.requires_escalated_permissions() {
|
||||
return true;
|
||||
}
|
||||
|
||||
additional_permissions
|
||||
.and_then(|permissions| permissions.file_system.as_ref())
|
||||
.is_some_and(|permissions| {
|
||||
permissions.entries.iter().any(|entry| {
|
||||
entry.access == FileSystemAccessMode::Write
|
||||
&& file_system_path_overlaps_project(&entry.path, project_root)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn path_uri_is_dependency_manifest(path: &PathUri) -> bool {
|
||||
path.to_abs_path()
|
||||
.is_ok_and(|path| is_dependency_manifest_path(path.as_path()))
|
||||
}
|
||||
|
||||
fn file_system_path_overlaps_project(
|
||||
path: &FileSystemPath<AbsolutePathBuf>,
|
||||
project_root: &AbsolutePathBuf,
|
||||
) -> bool {
|
||||
match path {
|
||||
FileSystemPath::Path { path } => {
|
||||
is_dependency_manifest_path(path.as_path())
|
||||
|| path.as_path().starts_with(project_root.as_path())
|
||||
|| project_root.as_path().starts_with(path.as_path())
|
||||
}
|
||||
FileSystemPath::GlobPattern { .. } => true,
|
||||
FileSystemPath::Special { value } => matches!(
|
||||
value,
|
||||
FileSystemSpecialPath::Root
|
||||
| FileSystemSpecialPath::ProjectRoots { .. }
|
||||
| FileSystemSpecialPath::Tmpdir
|
||||
| FileSystemSpecialPath::SlashTmp
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn write_profile(path: FileSystemPath<AbsolutePathBuf>) -> AdditionalPermissionProfile {
|
||||
AdditionalPermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
entries: vec![FileSystemSandboxEntry {
|
||||
path,
|
||||
access: FileSystemAccessMode::Write,
|
||||
}],
|
||||
glob_scan_max_depth: None,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_full_escalation_and_project_writes() {
|
||||
let root = AbsolutePathBuf::from_absolute_path("/workspace/project")
|
||||
.expect("absolute project root");
|
||||
let nested = AbsolutePathBuf::from_absolute_path("/workspace/project/packages/app")
|
||||
.expect("absolute nested path");
|
||||
let profile = write_profile(FileSystemPath::Path { path: nested });
|
||||
|
||||
assert!(dependency_permissions_overlap_project(
|
||||
SandboxPermissions::RequireEscalated,
|
||||
None,
|
||||
&root,
|
||||
));
|
||||
assert!(dependency_permissions_overlap_project(
|
||||
SandboxPermissions::WithAdditionalPermissions,
|
||||
Some(&profile),
|
||||
&root,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permits_unrelated_write_permissions() {
|
||||
let root = AbsolutePathBuf::from_absolute_path("/workspace/project")
|
||||
.expect("absolute project root");
|
||||
let cache = AbsolutePathBuf::from_absolute_path("/var/cache/example")
|
||||
.expect("absolute cache path");
|
||||
let profile = write_profile(FileSystemPath::Path { path: cache });
|
||||
|
||||
assert_eq!(
|
||||
dependency_permissions_overlap_project(
|
||||
SandboxPermissions::WithAdditionalPermissions,
|
||||
Some(&profile),
|
||||
&root,
|
||||
),
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ pub(crate) mod agent_jobs_spec;
|
||||
pub(crate) mod apply_patch;
|
||||
pub(crate) mod apply_patch_spec;
|
||||
mod current_time;
|
||||
mod dependency_check;
|
||||
mod dependency_check_output;
|
||||
mod dependency_guard;
|
||||
mod dynamic;
|
||||
pub(crate) mod extension_tools;
|
||||
mod get_context_remaining;
|
||||
@@ -58,6 +61,9 @@ pub use apply_patch::ApplyPatchHandler;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
pub use current_time::CurrentTimeHandler;
|
||||
pub use dependency_check::DependencyCheckHandler;
|
||||
pub(crate) use dependency_check::dependency_install_redirect;
|
||||
pub(crate) use dependency_check::dependency_manifest_edit_message;
|
||||
pub use dynamic::DynamicToolHandler;
|
||||
pub use get_context_remaining::GetContextRemainingHandler;
|
||||
pub use list_available_plugins_to_install::ListAvailablePluginsToInstallHandler;
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::tools::context::boxed_tool_output;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
use crate::tools::handlers::resolve_tool_environment;
|
||||
use crate::tools::handlers::dependency_guard::dependency_permission_request_message;
|
||||
use crate::tools::handlers::dependency_guard::dependency_permissions_overlap_project;
|
||||
use crate::tools::handlers::shell_spec::create_request_permissions_tool;
|
||||
use crate::tools::handlers::shell_spec::request_permissions_tool_description;
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
@@ -91,6 +93,19 @@ impl RequestPermissionsHandler {
|
||||
"request_permissions requires at least one permission".to_string(),
|
||||
));
|
||||
}
|
||||
if session.features().enabled(codex_features::Feature::DependencyCheck) {
|
||||
let permissions: codex_protocol::models::AdditionalPermissionProfile =
|
||||
args.permissions.clone().into();
|
||||
if dependency_permissions_overlap_project(
|
||||
codex_protocol::models::SandboxPermissions::WithAdditionalPermissions,
|
||||
Some(&permissions),
|
||||
&native_cwd,
|
||||
) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
dependency_permission_request_message(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let response = session
|
||||
.request_permissions_for_environment(
|
||||
|
||||
@@ -16,6 +16,9 @@ use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::handlers::apply_granted_turn_permissions;
|
||||
use crate::tools::handlers::apply_patch::intercept_apply_patch;
|
||||
use crate::tools::handlers::dependency_guard::dependency_permission_request_message;
|
||||
use crate::tools::handlers::dependency_guard::dependency_permissions_overlap_project;
|
||||
use crate::tools::handlers::dependency_install_redirect;
|
||||
use crate::tools::handlers::implicit_granted_permissions;
|
||||
use crate::tools::handlers::normalize_and_validate_additional_permissions;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
@@ -24,6 +27,7 @@ use crate::tools::runtimes::shell::ShellRequest;
|
||||
use crate::tools::runtimes::shell::ShellRuntime;
|
||||
use crate::tools::runtimes::shell::ShellRuntimeBackend;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
use crate::tools::sandboxing::ApprovalReviewMode;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::protocol::ExecCommandSource;
|
||||
use codex_tools::ToolName;
|
||||
@@ -77,6 +81,12 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
|
||||
shell_runtime_backend,
|
||||
} = args;
|
||||
|
||||
if session.features().enabled(Feature::DependencyCheck)
|
||||
&& let Some(message) = dependency_install_redirect(&exec_params.command)
|
||||
{
|
||||
return Ok(FunctionToolOutput::from_text(message, Some(false)));
|
||||
}
|
||||
|
||||
let fs = turn_environment.environment.get_filesystem();
|
||||
|
||||
let explicit_env_overrides = turn
|
||||
@@ -119,6 +129,18 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
|
||||
)
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
|
||||
if session.features().enabled(Feature::DependencyCheck)
|
||||
&& dependency_permissions_overlap_project(
|
||||
effective_additional_permissions.sandbox_permissions,
|
||||
normalized_additional_permissions.as_ref(),
|
||||
&exec_params.cwd,
|
||||
)
|
||||
{
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
dependency_permission_request_message(),
|
||||
));
|
||||
}
|
||||
|
||||
// Approval policy guard for explicit escalation in non-OnRequest modes.
|
||||
// Sticky turn permissions have already been approved, so they should
|
||||
// continue through the normal exec approval flow for the command.
|
||||
@@ -200,6 +222,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
|
||||
.permissions_preapproved,
|
||||
justification: exec_params.justification.clone(),
|
||||
exec_approval_requirement,
|
||||
approval_review_mode: ApprovalReviewMode::Configured,
|
||||
};
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
let mut runtime = ShellRuntime::for_shell_command(shell_runtime_backend);
|
||||
|
||||
@@ -9,6 +9,9 @@ use crate::tools::context::ToolPayload;
|
||||
use crate::tools::context::boxed_tool_output;
|
||||
use crate::tools::handlers::apply_granted_turn_permissions;
|
||||
use crate::tools::handlers::apply_patch::intercept_apply_patch;
|
||||
use crate::tools::handlers::dependency_guard::dependency_permission_request_message;
|
||||
use crate::tools::handlers::dependency_guard::dependency_permissions_overlap_project;
|
||||
use crate::tools::handlers::dependency_install_redirect;
|
||||
use crate::tools::handlers::implicit_granted_permissions;
|
||||
use crate::tools::handlers::normalize_and_validate_additional_permissions;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
@@ -228,7 +231,6 @@ impl ExecCommandHandler {
|
||||
)));
|
||||
}
|
||||
}
|
||||
let process_id = manager.allocate_process_id().await;
|
||||
let resolved_command = get_command(
|
||||
&args,
|
||||
shell,
|
||||
@@ -240,6 +242,25 @@ impl ExecCommandHandler {
|
||||
let shell_type = resolved_command.shell_type;
|
||||
let command_for_display = codex_shell_command::parse_command::shlex_join(&command);
|
||||
|
||||
if session.features().enabled(Feature::DependencyCheck)
|
||||
&& let Some(message) = dependency_install_redirect(&command)
|
||||
{
|
||||
return Ok(boxed_tool_output(ExecCommandToolOutput {
|
||||
event_call_id: context.call_id.clone(),
|
||||
chunk_id: generate_chunk_id(),
|
||||
wall_time: std::time::Duration::ZERO,
|
||||
raw_output: message.into_bytes(),
|
||||
truncation_policy: turn.model_info.truncation_policy.into(),
|
||||
max_output_tokens: None,
|
||||
process_id: None,
|
||||
exit_code: Some(2),
|
||||
original_token_count: None,
|
||||
hook_command: Some(hook_command),
|
||||
}));
|
||||
}
|
||||
|
||||
let process_id = manager.allocate_process_id().await;
|
||||
|
||||
let ExecCommandArgs {
|
||||
tty,
|
||||
yield_time_ms,
|
||||
@@ -311,6 +332,19 @@ impl ExecCommandHandler {
|
||||
}
|
||||
};
|
||||
|
||||
if session.features().enabled(Feature::DependencyCheck)
|
||||
&& dependency_permissions_overlap_project(
|
||||
effective_additional_permissions.sandbox_permissions,
|
||||
normalized_additional_permissions.as_ref(),
|
||||
permission_cwd,
|
||||
)
|
||||
{
|
||||
manager.release_process_id(process_id).await;
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
dependency_permission_request_message(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(output) = intercept_apply_patch(
|
||||
&command,
|
||||
&cwd,
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::tools::network_approval::begin_network_approval;
|
||||
use crate::tools::network_approval::finish_deferred_network_approval;
|
||||
use crate::tools::network_approval::finish_immediate_network_approval;
|
||||
use crate::tools::sandboxing::ApprovalCtx;
|
||||
use crate::tools::sandboxing::ApprovalReviewMode;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
use crate::tools::sandboxing::SandboxOverride;
|
||||
@@ -155,7 +156,9 @@ impl ToolOrchestrator {
|
||||
let otel_tn = flat_tool_name(&tool_ctx.tool_name).into_owned();
|
||||
let otel_ci = &tool_ctx.call_id;
|
||||
let strict_auto_review = tool_ctx.session.strict_auto_review_enabled_for_turn().await;
|
||||
let use_guardian = routes_approval_to_guardian(turn_ctx) || strict_auto_review;
|
||||
let force_user_review = tool.approval_review_mode(req) == ApprovalReviewMode::User;
|
||||
let use_guardian =
|
||||
!force_user_review && (routes_approval_to_guardian(turn_ctx) || strict_auto_review);
|
||||
|
||||
// 1) Approval
|
||||
let mut already_approved = false;
|
||||
@@ -183,7 +186,7 @@ impl ToolOrchestrator {
|
||||
tool_ctx.call_id.as_str(),
|
||||
approval_ctx,
|
||||
tool_ctx,
|
||||
/*evaluate_permission_request_hooks*/ false,
|
||||
/*evaluate_permission_request_hooks*/ force_user_review,
|
||||
&otel,
|
||||
)
|
||||
.await?;
|
||||
@@ -218,7 +221,7 @@ impl ToolOrchestrator {
|
||||
tool_ctx.call_id.as_str(),
|
||||
approval_ctx,
|
||||
tool_ctx,
|
||||
/*evaluate_permission_request_hooks*/ !strict_auto_review,
|
||||
/*evaluate_permission_request_hooks*/ !strict_auto_review || force_user_review,
|
||||
&otel,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -29,6 +29,7 @@ use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
|
||||
use crate::tools::sandboxing::Approvable;
|
||||
use crate::tools::sandboxing::ApprovalAction;
|
||||
use crate::tools::sandboxing::ApprovalCtx;
|
||||
use crate::tools::sandboxing::ApprovalReviewMode;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use crate::tools::sandboxing::PermissionRequestPayload;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
@@ -68,6 +69,7 @@ pub struct ShellRequest {
|
||||
pub additional_permissions_preapproved: bool,
|
||||
pub justification: Option<String>,
|
||||
pub exec_approval_requirement: ExecApprovalRequirement,
|
||||
pub approval_review_mode: ApprovalReviewMode,
|
||||
}
|
||||
|
||||
/// Selects `ShellRuntime` behavior for different callers.
|
||||
@@ -205,6 +207,10 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
fn sandbox_permissions(&self, req: &ShellRequest) -> SandboxPermissions {
|
||||
req.sandbox_permissions
|
||||
}
|
||||
|
||||
fn approval_review_mode(&self, req: &ShellRequest) -> ApprovalReviewMode {
|
||||
req.approval_review_mode
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
|
||||
@@ -32,6 +32,7 @@ async fn approval_key_includes_environment_id() {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
approval_review_mode: ApprovalReviewMode::Configured,
|
||||
};
|
||||
let runtime = ShellRuntime::for_shell_command(ShellRuntimeBackend::ShellCommandClassic);
|
||||
let original_key = runtime.approval_keys(&request);
|
||||
|
||||
@@ -335,6 +335,10 @@ pub(crate) trait Approvable<Req> {
|
||||
SandboxPermissions::UseDefault
|
||||
}
|
||||
|
||||
fn approval_review_mode(&self, _req: &Req) -> ApprovalReviewMode {
|
||||
ApprovalReviewMode::Configured
|
||||
}
|
||||
|
||||
fn should_bypass_approval(&self, policy: AskForApproval, already_approved: bool) -> bool {
|
||||
if already_approved {
|
||||
// We do not ask one more time
|
||||
@@ -374,6 +378,13 @@ pub(crate) trait Approvable<Req> {
|
||||
fn approval_action(&self, req: &Req, ctx: &ApprovalCtx<'_>) -> std::io::Result<ApprovalAction>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) enum ApprovalReviewMode {
|
||||
#[default]
|
||||
Configured,
|
||||
User,
|
||||
}
|
||||
|
||||
pub(crate) trait Sandboxable {
|
||||
fn sandbox_preference(&self) -> SandboxablePreference;
|
||||
fn escalate_on_failure(&self) -> bool {
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::tools::handlers::ApplyPatchHandler;
|
||||
use crate::tools::handlers::CodeModeExecuteHandler;
|
||||
use crate::tools::handlers::CodeModeWaitHandler;
|
||||
use crate::tools::handlers::CurrentTimeHandler;
|
||||
use crate::tools::handlers::DependencyCheckHandler;
|
||||
use crate::tools::handlers::DynamicToolHandler;
|
||||
use crate::tools::handlers::ExecCommandHandler;
|
||||
use crate::tools::handlers::ExecCommandHandlerOptions;
|
||||
@@ -712,6 +713,10 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
|
||||
|
||||
planned_tools.add(PlanHandler);
|
||||
|
||||
if features.enabled(Feature::DependencyCheck) {
|
||||
planned_tools.add(DependencyCheckHandler);
|
||||
}
|
||||
|
||||
if features.enabled(Feature::DeferredExecutor) {
|
||||
planned_tools.add(WaitForEnvironmentHandler);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "dependency-check",
|
||||
crate_name = "codex_dependency_check",
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "codex-dependency-check"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_dependency_check"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-client = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
semver = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
shlex = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
@@ -0,0 +1,148 @@
|
||||
use codex_shell_command::parse_command::extract_shell_command;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum PackageManager {
|
||||
Npm,
|
||||
Pnpm,
|
||||
Yarn,
|
||||
Bun,
|
||||
}
|
||||
|
||||
impl PackageManager {
|
||||
pub fn command_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Npm => "npm",
|
||||
Self::Pnpm => "pnpm",
|
||||
Self::Yarn => "yarn",
|
||||
Self::Bun => "bun",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub struct DependencyInstallCommand {
|
||||
pub package_manager: PackageManager,
|
||||
}
|
||||
|
||||
pub fn detect_dependency_install_command(command: &[String]) -> Option<DependencyInstallCommand> {
|
||||
let tokens = if let Some((_, script)) = extract_shell_command(command) {
|
||||
shlex::split(script)?
|
||||
} else {
|
||||
command.to_vec()
|
||||
};
|
||||
if tokens.iter().any(|token| {
|
||||
matches!(
|
||||
token.as_str(),
|
||||
"&&" | "||" | ";" | "|" | "&" | ">" | ">>" | "<"
|
||||
)
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let executable = Path::new(tokens.first()?).file_name()?.to_str()?;
|
||||
let package_manager = match executable {
|
||||
"npm" => PackageManager::Npm,
|
||||
"pnpm" => PackageManager::Pnpm,
|
||||
"yarn" => PackageManager::Yarn,
|
||||
"bun" => PackageManager::Bun,
|
||||
_ => return None,
|
||||
};
|
||||
let subcommand = tokens.get(1)?.as_str();
|
||||
let is_add = match package_manager {
|
||||
PackageManager::Npm => {
|
||||
matches!(subcommand, "install" | "i" | "add") && has_dependency_argument(&tokens[2..])
|
||||
}
|
||||
PackageManager::Pnpm => {
|
||||
subcommand == "add"
|
||||
|| (subcommand == "install" && has_dependency_argument(&tokens[2..]))
|
||||
}
|
||||
PackageManager::Yarn | PackageManager::Bun => subcommand == "add",
|
||||
};
|
||||
|
||||
is_add.then_some(DependencyInstallCommand { package_manager })
|
||||
}
|
||||
|
||||
pub fn is_dependency_manifest_path(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
matches!(
|
||||
name,
|
||||
"package.json"
|
||||
| "package-lock.json"
|
||||
| "npm-shrinkwrap.json"
|
||||
| "pnpm-lock.yaml"
|
||||
| "yarn.lock"
|
||||
| "bun.lock"
|
||||
| "bun.lockb"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn has_dependency_argument(arguments: &[String]) -> bool {
|
||||
arguments.iter().any(|argument| {
|
||||
!argument.starts_with('-') && !matches!(argument.as_str(), "install" | "add")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn strings(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_common_dependency_add_commands() {
|
||||
for (command, package_manager) in [
|
||||
(
|
||||
strings(&["npm", "install", "zod@3.23.8"]),
|
||||
PackageManager::Npm,
|
||||
),
|
||||
(
|
||||
strings(&["pnpm", "add", "zod@3.23.8"]),
|
||||
PackageManager::Pnpm,
|
||||
),
|
||||
(
|
||||
strings(&["yarn", "add", "zod@3.23.8"]),
|
||||
PackageManager::Yarn,
|
||||
),
|
||||
(strings(&["bun", "add", "zod@3.23.8"]), PackageManager::Bun),
|
||||
(
|
||||
strings(&["/bin/zsh", "-lc", "npm install --save-dev zod@3.23.8"]),
|
||||
PackageManager::Npm,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
detect_dependency_install_command(&command),
|
||||
Some(DependencyInstallCommand { package_manager })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_plain_installs_and_shell_indirection() {
|
||||
assert_eq!(
|
||||
detect_dependency_install_command(&strings(&["npm", "install"])),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
detect_dependency_install_command(&strings(&[
|
||||
"/bin/zsh",
|
||||
"-lc",
|
||||
"npm install zod@3.23.8 && echo done"
|
||||
])),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_dependency_manifest_paths() {
|
||||
assert!(is_dependency_manifest_path(Path::new("web/package.json")));
|
||||
assert!(is_dependency_manifest_path(Path::new("package-lock.json")));
|
||||
assert!(!is_dependency_manifest_path(Path::new("Cargo.toml")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
mod command;
|
||||
mod npm;
|
||||
mod osv;
|
||||
mod request;
|
||||
|
||||
pub use command::DependencyInstallCommand;
|
||||
pub use command::PackageManager;
|
||||
pub use command::detect_dependency_install_command;
|
||||
pub use command::is_dependency_manifest_path;
|
||||
pub use npm::NpmGraph;
|
||||
pub use npm::NpmGraphError;
|
||||
pub use npm::NpmGraphMismatch;
|
||||
pub use npm::NpmInstalledGraph;
|
||||
pub use npm::NpmInstalledGraphMismatch;
|
||||
pub use npm::NpmInstalledPackage;
|
||||
pub use npm::NpmManifestError;
|
||||
pub use npm::npm_ci_command;
|
||||
pub use npm::npm_install_command;
|
||||
pub use npm::npm_query_installed_command;
|
||||
pub use npm::npm_query_lock_command;
|
||||
pub use npm::npm_rebuild_command;
|
||||
pub use npm::validate_npm_manifest;
|
||||
pub use osv::DependencyPolicyAction;
|
||||
pub use osv::DependencyPolicyReport;
|
||||
pub use osv::DependencyRisk;
|
||||
pub use osv::DependencyRiskKind;
|
||||
pub use osv::OsvClient;
|
||||
pub use osv::OsvError;
|
||||
pub use request::DependencyCheckRequest;
|
||||
pub use request::DependencyEcosystem;
|
||||
pub use request::DependencyKind;
|
||||
pub use request::DependencySpec;
|
||||
pub use request::RequestValidationError;
|
||||
@@ -0,0 +1,451 @@
|
||||
use crate::DependencyCheckRequest;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeSet;
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct NpmPackage {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub resolved: String,
|
||||
pub integrity: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct NpmGraph {
|
||||
packages: BTreeSet<NpmPackage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct NpmInstalledPackage {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub resolved: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct NpmInstalledGraph {
|
||||
packages: BTreeSet<NpmInstalledPackage>,
|
||||
}
|
||||
|
||||
impl NpmGraph {
|
||||
pub fn from_query_json(json: &str) -> Result<Self, NpmGraphError> {
|
||||
let nodes: Vec<NpmQueryNode> = serde_json::from_str(json)?;
|
||||
let mut packages = BTreeSet::new();
|
||||
|
||||
for node in nodes {
|
||||
if node.location.as_deref() == Some("") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = node.name.ok_or(NpmGraphError::MissingField("name"))?;
|
||||
let version = node.version.ok_or(NpmGraphError::MissingField("version"))?;
|
||||
let resolved = node
|
||||
.resolved
|
||||
.ok_or_else(|| NpmGraphError::UnsupportedSource {
|
||||
name: name.clone(),
|
||||
version: version.clone(),
|
||||
resolved: None,
|
||||
})?;
|
||||
let parsed = Url::parse(&resolved).map_err(|_| NpmGraphError::UnsupportedSource {
|
||||
name: name.clone(),
|
||||
version: version.clone(),
|
||||
resolved: Some(resolved.clone()),
|
||||
})?;
|
||||
if parsed.scheme() != "https" {
|
||||
return Err(NpmGraphError::UnsupportedSource {
|
||||
name,
|
||||
version,
|
||||
resolved: Some(resolved),
|
||||
});
|
||||
}
|
||||
let integrity = node
|
||||
.integrity
|
||||
.filter(|integrity| !integrity.is_empty())
|
||||
.ok_or_else(|| NpmGraphError::MissingIntegrity {
|
||||
name: name.clone(),
|
||||
version: version.clone(),
|
||||
})?;
|
||||
|
||||
packages.insert(NpmPackage {
|
||||
name,
|
||||
version,
|
||||
resolved,
|
||||
integrity,
|
||||
});
|
||||
}
|
||||
|
||||
if packages.is_empty() {
|
||||
return Err(NpmGraphError::EmptyGraph);
|
||||
}
|
||||
|
||||
Ok(Self { packages })
|
||||
}
|
||||
|
||||
pub fn packages(&self) -> &BTreeSet<NpmPackage> {
|
||||
&self.packages
|
||||
}
|
||||
|
||||
pub fn coordinates(&self) -> BTreeSet<(String, String)> {
|
||||
self.packages
|
||||
.iter()
|
||||
.map(|package| (package.name.clone(), package.version.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn compare(&self, actual: &Self) -> Result<(), NpmGraphMismatch> {
|
||||
if self == actual {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(NpmGraphMismatch {
|
||||
expected_only: self
|
||||
.packages
|
||||
.difference(&actual.packages)
|
||||
.cloned()
|
||||
.collect(),
|
||||
actual_only: actual
|
||||
.packages
|
||||
.difference(&self.packages)
|
||||
.cloned()
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn compare_installed(
|
||||
&self,
|
||||
actual: &NpmInstalledGraph,
|
||||
) -> Result<(), NpmInstalledGraphMismatch> {
|
||||
let expected = self
|
||||
.packages
|
||||
.iter()
|
||||
.map(|package| NpmInstalledPackage {
|
||||
name: package.name.clone(),
|
||||
version: package.version.clone(),
|
||||
resolved: package.resolved.clone(),
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
if expected == actual.packages {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(NpmInstalledGraphMismatch {
|
||||
expected_only: expected.difference(&actual.packages).cloned().collect(),
|
||||
actual_only: actual.packages.difference(&expected).cloned().collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl NpmInstalledGraph {
|
||||
pub fn from_query_json(json: &str) -> Result<Self, NpmGraphError> {
|
||||
let nodes: Vec<NpmQueryNode> = serde_json::from_str(json)?;
|
||||
let mut packages = BTreeSet::new();
|
||||
|
||||
for node in nodes {
|
||||
let Some(node) = parse_query_node(node)? else {
|
||||
continue;
|
||||
};
|
||||
packages.insert(NpmInstalledPackage {
|
||||
name: node.name,
|
||||
version: node.version,
|
||||
resolved: node.resolved,
|
||||
});
|
||||
}
|
||||
|
||||
if packages.is_empty() {
|
||||
return Err(NpmGraphError::EmptyGraph);
|
||||
}
|
||||
|
||||
Ok(Self { packages })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct NpmGraphMismatch {
|
||||
pub expected_only: Vec<NpmPackage>,
|
||||
pub actual_only: Vec<NpmPackage>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NpmGraphMismatch {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"checked and installed npm graphs differ ({} checked-only entries, {} installed-only entries)",
|
||||
self.expected_only.len(),
|
||||
self.actual_only.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NpmGraphMismatch {}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct NpmInstalledGraphMismatch {
|
||||
pub expected_only: Vec<NpmInstalledPackage>,
|
||||
pub actual_only: Vec<NpmInstalledPackage>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NpmInstalledGraphMismatch {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"checked and installed npm artifact graphs differ ({} checked-only entries, {} installed-only entries)",
|
||||
self.expected_only.len(),
|
||||
self.actual_only.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NpmInstalledGraphMismatch {}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum NpmGraphError {
|
||||
#[error("npm query returned invalid JSON: {0}")]
|
||||
InvalidJson(#[from] serde_json::Error),
|
||||
#[error("npm query result is missing required field `{0}`")]
|
||||
MissingField(&'static str),
|
||||
#[error("npm package `{name}@{version}` has an unsupported resolved source: {resolved:?}")]
|
||||
UnsupportedSource {
|
||||
name: String,
|
||||
version: String,
|
||||
resolved: Option<String>,
|
||||
},
|
||||
#[error("npm package `{name}@{version}` has no integrity value")]
|
||||
MissingIntegrity { name: String, version: String },
|
||||
#[error("npm resolved an empty dependency graph")]
|
||||
EmptyGraph,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum NpmManifestError {
|
||||
#[error("package.json is not valid JSON: {0}")]
|
||||
InvalidJson(#[from] serde_json::Error),
|
||||
#[error("npm workspaces are not supported by the first dependency_check implementation")]
|
||||
WorkspacesUnsupported,
|
||||
#[error(
|
||||
"project declares unsupported package manager `{0}`; this implementation supports npm projects only"
|
||||
)]
|
||||
UnsupportedPackageManager(String),
|
||||
}
|
||||
|
||||
pub fn validate_npm_manifest(contents: &str) -> Result<(), NpmManifestError> {
|
||||
let manifest: Value = serde_json::from_str(contents)?;
|
||||
if manifest
|
||||
.get("workspaces")
|
||||
.is_some_and(|workspaces| !workspaces.is_null())
|
||||
{
|
||||
return Err(NpmManifestError::WorkspacesUnsupported);
|
||||
}
|
||||
if let Some(package_manager) = manifest.get("packageManager").and_then(Value::as_str)
|
||||
&& !package_manager.starts_with("npm@")
|
||||
{
|
||||
return Err(NpmManifestError::UnsupportedPackageManager(
|
||||
package_manager.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn npm_install_command(
|
||||
request: Option<&DependencyCheckRequest>,
|
||||
package_lock_only: bool,
|
||||
) -> Vec<String> {
|
||||
let mut command = vec!["npm".to_string(), "install".to_string()];
|
||||
command.push("--ignore-scripts".to_string());
|
||||
if package_lock_only {
|
||||
command.push("--package-lock-only".to_string());
|
||||
}
|
||||
command.extend([
|
||||
"--no-audit".to_string(),
|
||||
"--no-fund".to_string(),
|
||||
"--save-exact".to_string(),
|
||||
]);
|
||||
|
||||
if let Some(request) = request {
|
||||
if let Some(flag) = request.dependency_kind.npm_save_flag() {
|
||||
command.push(flag.to_string());
|
||||
}
|
||||
command.extend(
|
||||
request
|
||||
.dependencies
|
||||
.iter()
|
||||
.map(crate::DependencySpec::npm_specifier),
|
||||
);
|
||||
}
|
||||
|
||||
command
|
||||
}
|
||||
|
||||
pub fn npm_query_lock_command() -> Vec<String> {
|
||||
vec![
|
||||
"npm".to_string(),
|
||||
"query".to_string(),
|
||||
"*".to_string(),
|
||||
"--json".to_string(),
|
||||
"--package-lock-only".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn npm_ci_command() -> Vec<String> {
|
||||
vec![
|
||||
"npm".to_string(),
|
||||
"ci".to_string(),
|
||||
"--ignore-scripts".to_string(),
|
||||
"--no-audit".to_string(),
|
||||
"--no-fund".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn npm_query_installed_command() -> Vec<String> {
|
||||
vec![
|
||||
"npm".to_string(),
|
||||
"query".to_string(),
|
||||
"*".to_string(),
|
||||
"--json".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn npm_rebuild_command() -> Vec<String> {
|
||||
vec!["npm".to_string(), "rebuild".to_string()]
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NpmQueryNode {
|
||||
name: Option<String>,
|
||||
version: Option<String>,
|
||||
resolved: Option<String>,
|
||||
integrity: Option<String>,
|
||||
location: Option<String>,
|
||||
}
|
||||
|
||||
struct ParsedNpmQueryNode {
|
||||
name: String,
|
||||
version: String,
|
||||
resolved: String,
|
||||
}
|
||||
|
||||
fn parse_query_node(node: NpmQueryNode) -> Result<Option<ParsedNpmQueryNode>, NpmGraphError> {
|
||||
if node.location.as_deref() == Some("") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let name = node.name.ok_or(NpmGraphError::MissingField("name"))?;
|
||||
let version = node.version.ok_or(NpmGraphError::MissingField("version"))?;
|
||||
let resolved = node
|
||||
.resolved
|
||||
.ok_or_else(|| NpmGraphError::UnsupportedSource {
|
||||
name: name.clone(),
|
||||
version: version.clone(),
|
||||
resolved: None,
|
||||
})?;
|
||||
let parsed = Url::parse(&resolved).map_err(|_| NpmGraphError::UnsupportedSource {
|
||||
name: name.clone(),
|
||||
version: version.clone(),
|
||||
resolved: Some(resolved.clone()),
|
||||
})?;
|
||||
if parsed.scheme() != "https" {
|
||||
return Err(NpmGraphError::UnsupportedSource {
|
||||
name,
|
||||
version,
|
||||
resolved: Some(resolved),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Some(ParsedNpmQueryNode {
|
||||
name,
|
||||
version,
|
||||
resolved,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DependencyEcosystem;
|
||||
use crate::DependencyKind;
|
||||
use crate::DependencySpec;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn request() -> DependencyCheckRequest {
|
||||
DependencyCheckRequest {
|
||||
ecosystem: DependencyEcosystem::Npm,
|
||||
dependencies: vec![DependencySpec {
|
||||
name: "zod".to_string(),
|
||||
version: "3.23.8".to_string(),
|
||||
}],
|
||||
dependency_kind: DependencyKind::Development,
|
||||
workdir: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_script_disabled_npm_commands() {
|
||||
assert_eq!(
|
||||
npm_install_command(Some(&request()), /*package_lock_only*/ true),
|
||||
vec![
|
||||
"npm",
|
||||
"install",
|
||||
"--ignore-scripts",
|
||||
"--package-lock-only",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--save-exact",
|
||||
"--save-dev",
|
||||
"zod@3.23.8",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
npm_ci_command(),
|
||||
vec!["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"]
|
||||
);
|
||||
assert_eq!(npm_rebuild_command(), vec!["npm", "rebuild"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_and_compares_lock_graphs() {
|
||||
let graph = NpmGraph::from_query_json(
|
||||
r#"[
|
||||
{"name":"example","version":"1.0.0","location":"","resolved":null},
|
||||
{"name":"zod","version":"3.23.8","location":"node_modules/zod","resolved":"https://registry.npmjs.org/zod/-/zod-3.23.8.tgz","integrity":"sha512-example"}
|
||||
]"#,
|
||||
)
|
||||
.expect("parse graph");
|
||||
assert_eq!(
|
||||
graph.coordinates(),
|
||||
BTreeSet::from([("zod".to_string(), "3.23.8".to_string())])
|
||||
);
|
||||
assert_eq!(graph.compare(&graph), Ok(()));
|
||||
|
||||
let installed = NpmInstalledGraph::from_query_json(
|
||||
r#"[
|
||||
{"name":"example","version":"1.0.0","location":"","resolved":null},
|
||||
{"name":"zod","version":"3.23.8","location":"node_modules/zod","resolved":"https://registry.npmjs.org/zod/-/zod-3.23.8.tgz"}
|
||||
]"#,
|
||||
)
|
||||
.expect("parse installed graph");
|
||||
assert_eq!(graph.compare_installed(&installed), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unverifiable_sources() {
|
||||
let err = NpmGraph::from_query_json(
|
||||
r#"[{"name":"local","version":"1.0.0","location":"node_modules/local","resolved":"file:../local"}]"#,
|
||||
)
|
||||
.expect_err("local source should fail");
|
||||
assert!(matches!(err, NpmGraphError::UnsupportedSource { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_workspaces_and_non_npm_projects() {
|
||||
assert!(matches!(
|
||||
validate_npm_manifest(r#"{"workspaces":["packages/*"]}"#),
|
||||
Err(NpmManifestError::WorkspacesUnsupported)
|
||||
));
|
||||
assert!(matches!(
|
||||
validate_npm_manifest(r#"{"packageManager":"pnpm@10.0.0"}"#),
|
||||
Err(NpmManifestError::UnsupportedPackageManager(manager)) if manager == "pnpm@10.0.0"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
use crate::NpmGraph;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
const DEFAULT_OSV_BATCH_URL: &str = "https://api.osv.dev/v1/querybatch";
|
||||
const OSV_BATCH_SIZE: usize = 1_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum DependencyPolicyAction {
|
||||
Allow,
|
||||
Warn,
|
||||
Block,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum DependencyRiskKind {
|
||||
Malware,
|
||||
Vulnerability,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct DependencyRisk {
|
||||
pub package_name: String,
|
||||
pub package_version: String,
|
||||
pub kind: DependencyRiskKind,
|
||||
pub advisory_id: String,
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct DependencyPolicyReport {
|
||||
pub action: DependencyPolicyAction,
|
||||
pub checked_packages: usize,
|
||||
pub risks: Vec<DependencyRisk>,
|
||||
}
|
||||
|
||||
pub struct OsvClient {
|
||||
client: Client,
|
||||
endpoint: Url,
|
||||
}
|
||||
|
||||
impl OsvClient {
|
||||
pub fn new() -> Result<Self, OsvError> {
|
||||
Self::with_endpoint(Url::parse(DEFAULT_OSV_BATCH_URL).map_err(OsvError::InvalidEndpoint)?)
|
||||
}
|
||||
|
||||
pub fn with_endpoint(endpoint: Url) -> Result<Self, OsvError> {
|
||||
let client = Client::builder().timeout(Duration::from_secs(30));
|
||||
let client = codex_client::build_reqwest_client_with_custom_ca(client)
|
||||
.map_err(|err| OsvError::BuildClient(err.to_string()))?;
|
||||
Ok(Self { client, endpoint })
|
||||
}
|
||||
|
||||
pub async fn evaluate(&self, graph: &NpmGraph) -> Result<DependencyPolicyReport, OsvError> {
|
||||
let coordinates = graph.coordinates().into_iter().collect::<Vec<_>>();
|
||||
let mut risks = Vec::new();
|
||||
|
||||
for chunk in coordinates.chunks(OSV_BATCH_SIZE) {
|
||||
let request = BatchRequest {
|
||||
queries: chunk
|
||||
.iter()
|
||||
.map(|(name, version)| Query {
|
||||
package: Package {
|
||||
ecosystem: "npm",
|
||||
name,
|
||||
},
|
||||
version,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.post(self.endpoint.clone())
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(OsvError::Request)?
|
||||
.error_for_status()
|
||||
.map_err(OsvError::Request)?
|
||||
.json::<BatchResponse>()
|
||||
.await
|
||||
.map_err(OsvError::Request)?;
|
||||
risks.extend(risks_from_response(chunk, response)?);
|
||||
}
|
||||
|
||||
let action = if risks
|
||||
.iter()
|
||||
.any(|risk| risk.kind == DependencyRiskKind::Malware)
|
||||
{
|
||||
DependencyPolicyAction::Block
|
||||
} else if risks.is_empty() {
|
||||
DependencyPolicyAction::Allow
|
||||
} else {
|
||||
DependencyPolicyAction::Warn
|
||||
};
|
||||
|
||||
Ok(DependencyPolicyReport {
|
||||
action,
|
||||
checked_packages: coordinates.len(),
|
||||
risks,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum OsvError {
|
||||
#[error("invalid OSV endpoint: {0}")]
|
||||
InvalidEndpoint(url::ParseError),
|
||||
#[error("failed to build OSV HTTP client: {0}")]
|
||||
BuildClient(String),
|
||||
#[error("OSV request failed: {0}")]
|
||||
Request(reqwest::Error),
|
||||
#[error("OSV returned {actual} results for {expected} package queries")]
|
||||
ResultCountMismatch { expected: usize, actual: usize },
|
||||
}
|
||||
|
||||
fn risks_from_response(
|
||||
coordinates: &[(String, String)],
|
||||
response: BatchResponse,
|
||||
) -> Result<Vec<DependencyRisk>, OsvError> {
|
||||
if coordinates.len() != response.results.len() {
|
||||
return Err(OsvError::ResultCountMismatch {
|
||||
expected: coordinates.len(),
|
||||
actual: response.results.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut risks = Vec::new();
|
||||
for ((name, version), result) in coordinates.iter().zip(response.results) {
|
||||
for vulnerability in result.vulns {
|
||||
if !seen.insert((name.clone(), version.clone(), vulnerability.id.clone())) {
|
||||
continue;
|
||||
}
|
||||
risks.push(DependencyRisk {
|
||||
package_name: name.clone(),
|
||||
package_version: version.clone(),
|
||||
kind: if vulnerability.id.starts_with("MAL-") {
|
||||
DependencyRiskKind::Malware
|
||||
} else {
|
||||
DependencyRiskKind::Vulnerability
|
||||
},
|
||||
advisory_id: vulnerability.id,
|
||||
summary: vulnerability.summary,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(risks)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct BatchRequest<'a> {
|
||||
queries: Vec<Query<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Query<'a> {
|
||||
package: Package<'a>,
|
||||
version: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Package<'a> {
|
||||
ecosystem: &'static str,
|
||||
name: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BatchResponse {
|
||||
results: Vec<QueryResult>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QueryResult {
|
||||
#[serde(default)]
|
||||
vulns: Vec<Vulnerability>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Vulnerability {
|
||||
id: String,
|
||||
summary: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn maps_malware_to_block_and_cves_to_warn() {
|
||||
let coordinates = vec![
|
||||
("malicious".to_string(), "1.0.0".to_string()),
|
||||
("vulnerable".to_string(), "2.0.0".to_string()),
|
||||
];
|
||||
let risks = risks_from_response(
|
||||
&coordinates,
|
||||
BatchResponse {
|
||||
results: vec![
|
||||
QueryResult {
|
||||
vulns: vec![Vulnerability {
|
||||
id: "MAL-2024-1".to_string(),
|
||||
summary: Some("malware".to_string()),
|
||||
}],
|
||||
},
|
||||
QueryResult {
|
||||
vulns: vec![Vulnerability {
|
||||
id: "CVE-2026-1".to_string(),
|
||||
summary: Some("vulnerability".to_string()),
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
.expect("map response");
|
||||
|
||||
assert_eq!(
|
||||
risks
|
||||
.iter()
|
||||
.map(|risk| risk.kind)
|
||||
.collect::<Vec<DependencyRiskKind>>(),
|
||||
vec![
|
||||
DependencyRiskKind::Malware,
|
||||
DependencyRiskKind::Vulnerability
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_partial_provider_responses() {
|
||||
let err = risks_from_response(
|
||||
&[("zod".to_string(), "3.23.8".to_string())],
|
||||
BatchResponse {
|
||||
results: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect_err("partial response should fail");
|
||||
assert!(matches!(err, OsvError::ResultCountMismatch { .. }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use semver::Version;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeSet;
|
||||
use thiserror::Error;
|
||||
|
||||
pub const MAX_DEPENDENCIES: usize = 20;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DependencyEcosystem {
|
||||
Npm,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DependencyKind {
|
||||
Runtime,
|
||||
Development,
|
||||
Optional,
|
||||
}
|
||||
|
||||
impl DependencyKind {
|
||||
pub fn npm_save_flag(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Runtime => None,
|
||||
Self::Development => Some("--save-dev"),
|
||||
Self::Optional => Some("--save-optional"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct DependencySpec {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl DependencySpec {
|
||||
pub fn npm_specifier(&self) -> String {
|
||||
format!("{}@{}", self.name, self.version)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct DependencyCheckRequest {
|
||||
pub ecosystem: DependencyEcosystem,
|
||||
pub dependencies: Vec<DependencySpec>,
|
||||
pub dependency_kind: DependencyKind,
|
||||
#[serde(default)]
|
||||
pub workdir: Option<String>,
|
||||
}
|
||||
|
||||
impl DependencyCheckRequest {
|
||||
pub fn validate(&self) -> Result<(), RequestValidationError> {
|
||||
if self.dependencies.is_empty() {
|
||||
return Err(RequestValidationError::EmptyDependencies);
|
||||
}
|
||||
if self.dependencies.len() > MAX_DEPENDENCIES {
|
||||
return Err(RequestValidationError::TooManyDependencies {
|
||||
actual: self.dependencies.len(),
|
||||
maximum: MAX_DEPENDENCIES,
|
||||
});
|
||||
}
|
||||
|
||||
let mut names = BTreeSet::new();
|
||||
for dependency in &self.dependencies {
|
||||
validate_package_name(&dependency.name)?;
|
||||
Version::parse(&dependency.version).map_err(|source| {
|
||||
RequestValidationError::InvalidExactVersion {
|
||||
name: dependency.name.clone(),
|
||||
version: dependency.version.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
if !names.insert(dependency.name.clone()) {
|
||||
return Err(RequestValidationError::DuplicateDependency(
|
||||
dependency.name.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RequestValidationError {
|
||||
#[error("dependency_check requires at least one dependency")]
|
||||
EmptyDependencies,
|
||||
#[error("dependency_check accepts at most {maximum} dependencies, received {actual}")]
|
||||
TooManyDependencies { actual: usize, maximum: usize },
|
||||
#[error("invalid npm package name `{0}`")]
|
||||
InvalidPackageName(String),
|
||||
#[error(
|
||||
"dependency `{name}` must use an exact semantic version; `{version}` is invalid: {source}"
|
||||
)]
|
||||
InvalidExactVersion {
|
||||
name: String,
|
||||
version: String,
|
||||
#[source]
|
||||
source: semver::Error,
|
||||
},
|
||||
#[error("dependency `{0}` was provided more than once")]
|
||||
DuplicateDependency(String),
|
||||
}
|
||||
|
||||
fn validate_package_name(name: &str) -> Result<(), RequestValidationError> {
|
||||
if name.is_empty() || name.len() > 214 || name.bytes().any(|byte| byte.is_ascii_uppercase()) {
|
||||
return Err(RequestValidationError::InvalidPackageName(name.to_string()));
|
||||
}
|
||||
|
||||
let valid = if let Some(scoped) = name.strip_prefix('@') {
|
||||
let mut parts = scoped.split('/');
|
||||
matches!((parts.next(), parts.next(), parts.next()), (Some(scope), Some(package), None) if valid_package_segment(scope) && valid_package_segment(package))
|
||||
} else {
|
||||
valid_package_segment(name)
|
||||
};
|
||||
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(RequestValidationError::InvalidPackageName(name.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_package_segment(segment: &str) -> bool {
|
||||
let mut bytes = segment.bytes();
|
||||
bytes
|
||||
.next()
|
||||
.is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
|
||||
&& bytes.all(|byte| {
|
||||
byte.is_ascii_lowercase()
|
||||
|| byte.is_ascii_digit()
|
||||
|| matches!(byte, b'-' | b'_' | b'.' | b'~')
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn request(dependencies: &[(&str, &str)]) -> DependencyCheckRequest {
|
||||
DependencyCheckRequest {
|
||||
ecosystem: DependencyEcosystem::Npm,
|
||||
dependencies: dependencies
|
||||
.iter()
|
||||
.map(|(name, version)| DependencySpec {
|
||||
name: (*name).to_string(),
|
||||
version: (*version).to_string(),
|
||||
})
|
||||
.collect(),
|
||||
dependency_kind: DependencyKind::Runtime,
|
||||
workdir: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_exact_scoped_and_unscoped_packages() {
|
||||
assert!(
|
||||
request(&[("zod", "3.23.8"), ("@types/node", "22.15.0")])
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ranges_tags_and_duplicate_names() {
|
||||
assert!(matches!(
|
||||
request(&[("zod", "^3.23.8")]).validate(),
|
||||
Err(RequestValidationError::InvalidExactVersion { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
request(&[("zod", "latest")]).validate(),
|
||||
Err(RequestValidationError::InvalidExactVersion { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
request(&[("zod", "3.23.8"), ("zod", "3.24.0")]).validate(),
|
||||
Err(RequestValidationError::DuplicateDependency(name)) if name == "zod"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_package_names() {
|
||||
for name in ["../zod", "Zod", "@scope", "@scope/../pkg", ""] {
|
||||
assert!(matches!(
|
||||
request(&[(name, "1.0.0")]).validate(),
|
||||
Err(RequestValidationError::InvalidPackageName(actual)) if actual == name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,8 @@ pub enum Feature {
|
||||
ToolSuggest,
|
||||
/// Enable plugins.
|
||||
Plugins,
|
||||
/// Check npm dependency graphs before lifecycle scripts run.
|
||||
DependencyCheck,
|
||||
/// Removed compatibility flag for plugin-bundled lifecycle hooks.
|
||||
PluginHooks,
|
||||
/// Allow the in-app browser pane in desktop apps.
|
||||
@@ -1366,6 +1368,16 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::DependencyCheck,
|
||||
key: "dependency_check",
|
||||
stage: Stage::Experimental {
|
||||
name: "Dependency Check",
|
||||
menu_description: "Resolve and check complete npm dependency graphs before package lifecycle scripts run, and redirect recognizable direct dependency additions to the structured tool.",
|
||||
announcement: "",
|
||||
},
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::WorkspaceDependencies,
|
||||
key: "workspace_dependencies",
|
||||
|
||||
@@ -240,6 +240,19 @@ fn browser_controls_are_stable_and_enabled_by_default() {
|
||||
assert_eq!(feature_for_key("computer_use"), Some(Feature::ComputerUse));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_check_is_experimental_and_disabled_by_default() {
|
||||
assert!(matches!(
|
||||
Feature::DependencyCheck.stage(),
|
||||
Stage::Experimental { .. }
|
||||
));
|
||||
assert_eq!(Feature::DependencyCheck.default_enabled(), false);
|
||||
assert_eq!(
|
||||
feature_for_key("dependency_check"),
|
||||
Some(Feature::DependencyCheck)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_linux_sandbox_bwrap_is_a_removed_feature_key() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
use crate::JsonSchema;
|
||||
use crate::ResponsesApiTool;
|
||||
use crate::ToolSpec;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub fn create_dependency_check_tool() -> ToolSpec {
|
||||
let dependency_properties = BTreeMap::from([
|
||||
(
|
||||
"name".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Exact npm package name, for example `zod` or `@types/node`.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"version".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Exact semantic version. Ranges and tags such as `^1.2.3` or `latest` are rejected."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]);
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"ecosystem".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Package ecosystem. The first implementation accepts only `npm`.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"dependencies".to_string(),
|
||||
JsonSchema::array(
|
||||
JsonSchema::object(
|
||||
dependency_properties,
|
||||
Some(vec!["name".to_string(), "version".to_string()]),
|
||||
Some(false.into()),
|
||||
),
|
||||
Some("One to twenty direct dependencies to add.".to_string()),
|
||||
),
|
||||
),
|
||||
(
|
||||
"dependency_kind".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Where to record the dependencies: `runtime`, `development`, or `optional`."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional project directory, relative to the current working directory unless absolute."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "dependency_check".to_string(),
|
||||
description: "Add exact npm dependencies through a checked two-phase install. Codex resolves the complete lock graph with lifecycle scripts disabled, queries OSV for every exact package version, updates the project lock graph through the normal sandbox and approval path, verifies it matches the checked graph, installs with scripts disabled, and only then runs npm rebuild. Use this instead of raw npm, pnpm, yarn, or bun dependency-add commands when the feature is enabled. The first implementation supports non-workspace npm projects only.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::object(
|
||||
properties,
|
||||
Some(vec![
|
||||
"ecosystem".to_string(),
|
||||
"dependencies".to_string(),
|
||||
"dependency_kind".to_string(),
|
||||
]),
|
||||
Some(false.into()),
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ResponsesApiTool;
|
||||
use crate::ToolSpec;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn dependency_check_tool_has_expected_contract() {
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
name, parameters, ..
|
||||
}) = create_dependency_check_tool()
|
||||
else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
assert_eq!(name, "dependency_check");
|
||||
assert_eq!(
|
||||
parameters.required,
|
||||
Some(vec![
|
||||
"ecosystem".to_string(),
|
||||
"dependencies".to_string(),
|
||||
"dependency_kind".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! outside `codex-core`.
|
||||
|
||||
mod code_mode;
|
||||
mod dependency_check_tool;
|
||||
mod dynamic_tool;
|
||||
mod function_call_error;
|
||||
mod image_detail;
|
||||
@@ -25,6 +26,7 @@ pub use code_mode::code_mode_name_for_tool_name;
|
||||
pub use code_mode::collect_code_mode_exec_prompt_tool_definitions;
|
||||
pub use code_mode::collect_code_mode_tool_definitions;
|
||||
pub use code_mode::tool_spec_to_code_mode_tool_definition;
|
||||
pub use dependency_check_tool::create_dependency_check_tool;
|
||||
pub use codex_protocol::ToolName;
|
||||
pub use dynamic_tool::parse_dynamic_tool;
|
||||
pub use function_call_error::FunctionCallError;
|
||||
|
||||
@@ -13,3 +13,10 @@ Admins can set top-level `allow_managed_hooks_only = true` in
|
||||
still allowing managed hooks from requirements and managed config layers. This
|
||||
setting is only supported in `requirements.toml`; putting it in `config.toml`
|
||||
does not enable managed-hooks-only mode.
|
||||
|
||||
## Dependency Check
|
||||
|
||||
The experimental `dependency_check` tool resolves and checks complete npm
|
||||
dependency graphs before lifecycle scripts run. Enable it with
|
||||
`features.dependency_check = true`. See [Dependency Check](dependency-check.md)
|
||||
for the sandbox policy and approval flow.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Dependency Check
|
||||
|
||||
`dependency_check` is an experimental npm-first tool that separates dependency
|
||||
resolution and policy from package lifecycle execution.
|
||||
|
||||
Enable it in `config.toml`:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
dependency_check = true
|
||||
```
|
||||
|
||||
The model-visible request uses exact package intent:
|
||||
|
||||
```json
|
||||
{
|
||||
"ecosystem": "npm",
|
||||
"dependencies": [{ "name": "zod", "version": "3.23.8" }],
|
||||
"dependency_kind": "development"
|
||||
}
|
||||
```
|
||||
|
||||
Version ranges and tags are rejected. A request may contain at most twenty
|
||||
direct dependencies.
|
||||
|
||||
## Execution flow
|
||||
|
||||
1. Copy `package.json`, an existing `package-lock.json`, and project `.npmrc`
|
||||
into a temporary directory.
|
||||
2. Run `npm install --package-lock-only --ignore-scripts` there to resolve the
|
||||
complete graph without running package lifecycle code.
|
||||
3. Run `npm query '*' --json --package-lock-only` and require every package to
|
||||
have an HTTPS artifact URL and integrity value.
|
||||
4. Query the OSV batch API for every unique exact npm package coordinate.
|
||||
5. Update the real project lock graph through Codex's normal sandbox and
|
||||
approval path, then require it to exactly match the checked graph.
|
||||
6. Run a clean `npm ci --ignore-scripts`, verify the installed artifact graph
|
||||
matches the checked graph, and only then run `npm rebuild`.
|
||||
|
||||
Known `MAL-*` advisories block the operation. Ordinary vulnerability advisories
|
||||
are reported as warnings. Provider failures, partial responses, unsupported
|
||||
sources, missing integrity values, and graph changes fail closed before
|
||||
lifecycle scripts run.
|
||||
|
||||
An OSV allow result means only that OSV returned no matching advisory. It is
|
||||
not a general assertion that a package is trustworthy.
|
||||
|
||||
## Enforcement
|
||||
|
||||
When the feature is enabled, Codex rejects recognizable direct dependency-add
|
||||
commands such as `npm install package@version`, `pnpm add`, `yarn add`, and
|
||||
`bun add`, directing the model to `dependency_check`. It also rejects direct
|
||||
`apply_patch` changes to JavaScript package manifests and lockfiles.
|
||||
|
||||
The real project mutation still uses the existing shell sandbox and approval
|
||||
orchestrator. A permission profile can therefore keep the root manifest and
|
||||
lockfile read-only inside the sandbox so the checked update produces a user
|
||||
approval prompt:
|
||||
|
||||
```toml
|
||||
default_permissions = "dependency-check"
|
||||
|
||||
[permissions.dependency-check]
|
||||
description = "Workspace write access with npm manifests read-only."
|
||||
|
||||
[permissions.dependency-check.filesystem]
|
||||
":minimal" = "read"
|
||||
":tmpdir" = "write"
|
||||
"/path/to/node/runtime" = "read"
|
||||
|
||||
[permissions.dependency-check.filesystem.":project_roots"]
|
||||
"." = "write"
|
||||
"package.json" = "read"
|
||||
"package-lock.json" = "read"
|
||||
|
||||
[permissions.dependency-check.network]
|
||||
enabled = true
|
||||
|
||||
[permissions.dependency-check.network.domains]
|
||||
"registry.npmjs.org" = "allow"
|
||||
"api.osv.dev" = "allow"
|
||||
```
|
||||
|
||||
Replace `/path/to/node/runtime` with the directory containing the configured
|
||||
Node.js and npm binaries, and ensure that directory's `bin` is on `PATH` when
|
||||
Codex starts. List only lockfiles that already exist: on Linux, a missing
|
||||
read-only path can be materialized as an empty bind-mount placeholder. Add
|
||||
`npm-shrinkwrap.json` only when the project already contains it.
|
||||
|
||||
This profile protects only those paths at the workspace root. Managed policy
|
||||
must add equivalent rules for nested package roots when they need the same
|
||||
filesystem enforcement. The tool grants its own temporary resolution
|
||||
directory as a preapproved, per-command write path; that does not widen access
|
||||
to the project. The checked project update separately requests per-command
|
||||
write access to `package.json` and `package-lock.json`; with `on-request`
|
||||
approval, that request produces the user prompt.
|
||||
|
||||
## Current limitations
|
||||
|
||||
- Only non-workspace npm projects are supported.
|
||||
- pnpm, Yarn, and Bun dependency additions are redirected but return an
|
||||
explicit unsupported-project result from the tool.
|
||||
- Git, file, workspace, HTTP, and other non-HTTPS package sources are blocked.
|
||||
- Static command recognition covers common direct commands. Arbitrary shell
|
||||
indirection is outside the redirect boundary and must be constrained by the
|
||||
filesystem sandbox policy.
|
||||
- The install phase may leave `package.json`, `package-lock.json`, or unpacked
|
||||
packages changed if npm fails after the approved project mutation. Lifecycle
|
||||
scripts remain disabled in those failure cases.
|
||||
Reference in New Issue
Block a user