This PR adds the API V2 version of the command‑execution approval flow
for the shell tool.
This PR wires the new RPC (`item/commandExecution/requestApproval`, V2
only) and related events (`item/started`, `item/completed`, and
`item/commandExecution/delta`, which are emitted in both V1 and V2)
through the app-server
protocol. The new approval RPC is only sent when the user initiates a
turn with the new `turn/start` API so we don't break backwards
compatibility with VSCE.
The approach I took was to make as few changes to the Codex core as
possible, leveraging existing `EventMsg` core events, and translating
those in app-server. I did have to add additional fields to
`EventMsg::ExecCommandEndEvent` to capture the command's input so that
app-server can statelessly transform these events to a
`ThreadItem::CommandExecution` item for the `item/completed` event.
Once we stabilize the API and it's complete enough for our partners, we
can work on migrating the core to be aware of command execution items as
a first-class concept.
**Note**: We'll need followup work to make sure these APIs work for the
unified exec tool, but will wait til that's stable and landed before
doing a pass on app-server.
Example payloads below:
```
{
"method": "item/started",
"params": {
"item": {
"aggregatedOutput": null,
"command": "/bin/zsh -lc 'touch /tmp/should-trigger-approval'",
"cwd": "/Users/owen/repos/codex/codex-rs",
"durationMs": null,
"exitCode": null,
"id": "call_lNWWsbXl1e47qNaYjFRs0dyU",
"parsedCmd": [
{
"cmd": "touch /tmp/should-trigger-approval",
"type": "unknown"
}
],
"status": "inProgress",
"type": "commandExecution"
}
}
}
```
```
{
"id": 0,
"method": "item/commandExecution/requestApproval",
"params": {
"itemId": "call_lNWWsbXl1e47qNaYjFRs0dyU",
"parsedCmd": [
{
"cmd": "touch /tmp/should-trigger-approval",
"type": "unknown"
}
],
"reason": "Need to create file in /tmp which is outside workspace sandbox",
"risk": null,
"threadId": "019a93e8-0a52-7fe3-9808-b6bc40c0989a",
"turnId": "1"
}
}
```
```
{
"id": 0,
"result": {
"acceptSettings": {
"forSession": false
},
"decision": "accept"
}
}
```
```
{
"params": {
"item": {
"aggregatedOutput": null,
"command": "/bin/zsh -lc 'touch /tmp/should-trigger-approval'",
"cwd": "/Users/owen/repos/codex/codex-rs",
"durationMs": 224,
"exitCode": 0,
"id": "call_lNWWsbXl1e47qNaYjFRs0dyU",
"parsedCmd": [
{
"cmd": "touch /tmp/should-trigger-approval",
"type": "unknown"
}
],
"status": "completed",
"type": "commandExecution"
}
}
}
```
67 lines
2.3 KiB
Rust
67 lines
2.3 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::parse_command::ParsedCommand;
|
|
use crate::protocol::FileChange;
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use ts_rs::TS;
|
|
|
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SandboxRiskLevel {
|
|
Low,
|
|
Medium,
|
|
High,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
|
pub struct SandboxCommandAssessment {
|
|
pub description: String,
|
|
pub risk_level: SandboxRiskLevel,
|
|
}
|
|
|
|
impl SandboxRiskLevel {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Low => "low",
|
|
Self::Medium => "medium",
|
|
Self::High => "high",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
|
pub struct ExecApprovalRequestEvent {
|
|
/// Identifier for the associated exec call, if available.
|
|
pub call_id: String,
|
|
/// Turn ID that this command belongs to.
|
|
/// Uses `#[serde(default)]` for backwards compatibility.
|
|
#[serde(default)]
|
|
pub turn_id: String,
|
|
/// The command to be executed.
|
|
pub command: Vec<String>,
|
|
/// The command's working directory.
|
|
pub cwd: PathBuf,
|
|
/// Optional human-readable reason for the approval (e.g. retry without sandbox).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub reason: Option<String>,
|
|
/// Optional model-provided risk assessment describing the blocked command.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub risk: Option<SandboxCommandAssessment>,
|
|
pub parsed_cmd: Vec<ParsedCommand>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
|
pub struct ApplyPatchApprovalRequestEvent {
|
|
/// Responses API call id for the associated patch apply call, if available.
|
|
pub call_id: String,
|
|
pub changes: HashMap<PathBuf, FileChange>,
|
|
/// Optional explanatory reason (e.g. request for extra write access).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub reason: Option<String>,
|
|
/// When set, the agent is asking the user to allow writes under this root for the remainder of the session.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub grant_root: Option<PathBuf>,
|
|
}
|