mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
feat(hooks): export a post-admission execution receipt to tool_call_after (#6689)
tool_call_after hooks could not tell what a shell tool actually ran. They
got the result text, the success flag, the exit code and the status, but not
the command or its directory. A tool_call_before rewrite can change the
before-hook input they would otherwise re-derive those from.
DEEPSEEK_TOOL_EXECUTION_RECEIPT now carries schema-1 JSON with these fields:
command, cwd, state (completed/interrupted), scope, exit_code (observed or
null), bounded stdout/stderr previews, truncation flags and output_kind.
- Identity reuses the spawn record. execute_foreground_via_background reads
command and working_dir from the BackgroundShell record the process manager
created, under the same lock that marks the run as foreground.
- HookContext::with_tool_outcome reads metadata.execution_receipt from Ok
and Err results. That one seam covers the TUI and Runtime API completion
hooks, and on_error for a failed shell call. Lowercase bash failures carry
the receipt too.
- Exact or absent. An identity over 8 KiB, containing NUL, or with a
relative or non-UTF-8 directory gets no receipt. Previews keep both ends
and halve until the serialized JSON is at most 32 KiB. The hook boundary
drops a non-schema-1 or oversized receipt instead of truncating it.
- Scope is settled, pipe-backed, unsandboxed, local foreground runs only. No
receipt for background, moved-to-/jobs, PTY/interactive, OS sandbox,
external backend, read-only argv hardening, Windows, or pre-exec refusals.
- Documented in docs/HOOKS.md and docs/zh_hans/HOOKS.md.
The contract and test design follow Isabel Wu's reference branch
wuisabel-gif/CodeWhale@feat/tool-call-after-execution-receipt. This version
is re-implemented on the current with_tool_outcome seam and on main's
process-manager record.
Evidence (macOS, CARGO_TARGET_DIR outside the repo):
cargo test -p codewhale-tui --lib -- execution_receipt
-> test result: ok. 3 passed; 0 failed; 0 ignored; 13816 filtered out
same run with the metadata read and identity capture disabled
-> test result: FAILED. 1 passed; 2 failed (both new behavior tests fail)
cargo test -p codewhale-tui --lib -- hooks:: tools::shell:: tool_routing
-> test result: ok. 340 passed; 0 failed; 1 ignored
cargo test -p codewhale-tui --lib -- runtime_tool_completion_fires_after_and_error_hooks runtime_shell_completion_delivers_exit_code_and_status_to_hooks
-> test result: ok. 2 passed; 0 failed
cargo clippy -p codewhale-tui --lib --tests --locked -- -D warnings (CI allow-list)
-> Finished, 0 warnings
cargo fmt --all -- --check -> clean
Co-authored-by: Isabel Wu <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
This commit is contained in:
1 parent
b36a347742
commit
b965cd7ddd
6 files changed
+492
-9
No files matched your search
@@ -31,9 +31,9 @@ pub use config::{
|
||||
PROJECT_HOOKS_TEMPLATE, workspace_allows_project_hooks,
|
||||
};
|
||||
pub(crate) use executor::{
|
||||
HOOK_CONTEXT_AGGREGATE_MAX_CHARS, HOOK_LABEL_MAX_CHARS, generic_unavailable_detail,
|
||||
parse_tool_call_before_stdout, sanitize_hook_denial_reason, sanitize_hook_label,
|
||||
sanitize_hook_line, sanitize_hook_text,
|
||||
HOOK_CONTEXT_AGGREGATE_MAX_CHARS, HOOK_EXECUTION_RECEIPT_MAX_BYTES, HOOK_LABEL_MAX_CHARS,
|
||||
generic_unavailable_detail, parse_tool_call_before_stdout, sanitize_hook_denial_reason,
|
||||
sanitize_hook_label, sanitize_hook_line, sanitize_hook_text,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use executor::{
|
||||
|
||||
@@ -47,6 +47,10 @@ pub struct HookContext {
|
||||
/// command usually has no exit code, so this is how a hook tells it apart
|
||||
/// from a tool that reported nothing.
|
||||
pub tool_status: Option<String>,
|
||||
/// Serialized post-admission shell execution receipt (#6689), exported as
|
||||
/// `DEEPSEEK_TOOL_EXECUTION_RECEIPT`. Complete JSON or absent — never a
|
||||
/// truncated document — and at most [`HOOK_EXECUTION_RECEIPT_MAX_BYTES`].
|
||||
pub tool_execution_receipt: Option<String>,
|
||||
/// Whether tool succeeded
|
||||
pub tool_success: Option<bool>,
|
||||
/// Current mode
|
||||
@@ -115,6 +119,7 @@ impl HookContext {
|
||||
};
|
||||
let mut context = self.with_tool_result(&text, success, reported_tool_exit_code(result));
|
||||
context.tool_status = reported_tool_status(result).map(str::to_string);
|
||||
context.tool_execution_receipt = reported_tool_execution_receipt(result);
|
||||
context
|
||||
}
|
||||
|
||||
@@ -179,6 +184,15 @@ impl HookContext {
|
||||
bound(&mut self.message, HOOK_MESSAGE_CONTEXT_MAX_BYTES);
|
||||
bound(&mut self.error_message, HOOK_ERROR_CONTEXT_MAX_BYTES);
|
||||
bound(&mut self.model, HOOK_OBSERVER_METADATA_MAX_BYTES);
|
||||
// A receipt is complete JSON or nothing: truncating it would export a
|
||||
// broken document, so an oversized one is dropped instead.
|
||||
if self
|
||||
.tool_execution_receipt
|
||||
.as_ref()
|
||||
.is_some_and(|receipt| receipt.len() > HOOK_EXECUTION_RECEIPT_MAX_BYTES)
|
||||
{
|
||||
self.tool_execution_receipt = None;
|
||||
}
|
||||
if let Some(workspace) = self.workspace.take() {
|
||||
self.workspace = Some(PathBuf::from(truncate_env_value(
|
||||
&workspace.to_string_lossy(),
|
||||
@@ -225,6 +239,14 @@ impl HookContext {
|
||||
if let Some(ref status) = self.tool_status {
|
||||
env.insert("DEEPSEEK_TOOL_STATUS".to_string(), status.clone());
|
||||
}
|
||||
if let Some(ref receipt) = self.tool_execution_receipt
|
||||
&& receipt.len() <= HOOK_EXECUTION_RECEIPT_MAX_BYTES
|
||||
{
|
||||
env.insert(
|
||||
"DEEPSEEK_TOOL_EXECUTION_RECEIPT".to_string(),
|
||||
receipt.clone(),
|
||||
);
|
||||
}
|
||||
if let Some(ref mode) = self.mode {
|
||||
env.insert("DEEPSEEK_MODE".to_string(), mode.clone());
|
||||
}
|
||||
@@ -405,6 +427,12 @@ const HOOK_TOOL_ARGS_ENV_MAX_BYTES: usize = 10_000;
|
||||
/// Largest raw tool result retained in an observer job before enqueue.
|
||||
const HOOK_TOOL_RESULT_CONTEXT_MAX_BYTES: usize = 10_000;
|
||||
|
||||
/// Largest serialized shell execution receipt exported through
|
||||
/// `DEEPSEEK_TOOL_EXECUTION_RECEIPT`. The shell tool fits its output previews
|
||||
/// beneath this bound; the hook boundary drops anything larger rather than
|
||||
/// truncate a JSON document.
|
||||
pub(crate) const HOOK_EXECUTION_RECEIPT_MAX_BYTES: usize = 32 * 1024;
|
||||
|
||||
/// Largest error retained in an observer job before enqueue.
|
||||
const HOOK_ERROR_CONTEXT_MAX_BYTES: usize = 5_000;
|
||||
|
||||
@@ -3011,6 +3039,24 @@ fn reported_tool_status(
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the post-admission execution receipt a shell tool recorded (#6689),
|
||||
/// serialized for `DEEPSEEK_TOOL_EXECUTION_RECEIPT`.
|
||||
///
|
||||
/// Only a schema-1 object within the size bound counts. The receipt is built
|
||||
/// by the shell tool from what its process manager recorded at spawn; it is
|
||||
/// never reconstructed here from the before-hook input, which can differ from
|
||||
/// what actually ran.
|
||||
fn reported_tool_execution_receipt(
|
||||
result: &Result<crate::tools::spec::ToolResult, crate::tools::spec::ToolError>,
|
||||
) -> Option<String> {
|
||||
let receipt = reported_tool_metadata(result)?.get("execution_receipt")?;
|
||||
if receipt.get("schema_version")?.as_u64()? != 1 {
|
||||
return None;
|
||||
}
|
||||
let encoded = serde_json::to_string(receipt).ok()?;
|
||||
(encoded.len() <= HOOK_EXECUTION_RECEIPT_MAX_BYTES).then_some(encoded)
|
||||
}
|
||||
|
||||
/// Read the process exit code a tool reported, when it reported one.
|
||||
///
|
||||
/// The one source for `DEEPSEEK_TOOL_EXIT_CODE`: the TUI and the Runtime API
|
||||
@@ -5617,6 +5663,78 @@ command = "echo project"
|
||||
);
|
||||
}
|
||||
|
||||
/// #6689: `DEEPSEEK_TOOL_EXECUTION_RECEIPT` is read from the metadata a
|
||||
/// shell tool recorded — on a failed call as well as a successful one —
|
||||
/// and is complete JSON or absent. The existing variables do not change.
|
||||
#[test]
|
||||
fn execution_receipt_env_is_complete_json_or_absent() {
|
||||
use crate::tools::spec::{ToolError, ToolResult};
|
||||
|
||||
let receipt = json!({"schema_version": 1, "command": "printf effective",
|
||||
"cwd": "/tmp", "state": "completed", "scope": "local", "exit_code": 7,
|
||||
"stdout": "\u{1f40b}", "stderr": "", "stdout_truncated": false,
|
||||
"stderr_truncated": false, "output_kind": "separate"});
|
||||
let plain = HookContext::new()
|
||||
.with_tool_name("Bash")
|
||||
.with_tool_outcome(&Ok(ToolResult::success("out")));
|
||||
let legacy = plain.to_env_vars();
|
||||
assert!(!legacy.contains_key("DEEPSEEK_TOOL_EXECUTION_RECEIPT"));
|
||||
|
||||
let with_receipt = HookContext::new()
|
||||
.with_tool_name("Bash")
|
||||
.with_tool_outcome(&Ok(
|
||||
ToolResult::success("out").with_metadata(json!({"execution_receipt": receipt}))
|
||||
));
|
||||
let mut env = with_receipt.to_env_vars();
|
||||
let encoded = env
|
||||
.remove("DEEPSEEK_TOOL_EXECUTION_RECEIPT")
|
||||
.expect("receipt exported");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&encoded).unwrap(),
|
||||
receipt
|
||||
);
|
||||
assert_eq!(env, legacy, "existing variables are unchanged");
|
||||
|
||||
let failed =
|
||||
HookContext::new().with_tool_outcome(&Err(ToolError::execution_failed_with_metadata(
|
||||
"boom",
|
||||
json!({"exit_code": 7, "execution_receipt": receipt}),
|
||||
)));
|
||||
assert!(
|
||||
failed
|
||||
.to_env_vars()
|
||||
.contains_key("DEEPSEEK_TOOL_EXECUTION_RECEIPT")
|
||||
);
|
||||
|
||||
// An unknown schema or an oversized document is dropped, not cut.
|
||||
for bad in [
|
||||
json!({"schema_version": 2, "command": "x"}),
|
||||
json!({"command": "x"}),
|
||||
json!({"schema_version": 1,
|
||||
"stdout": "x".repeat(super::HOOK_EXECUTION_RECEIPT_MAX_BYTES)}),
|
||||
] {
|
||||
let context = HookContext::new().with_tool_outcome(&Ok(
|
||||
ToolResult::success("out").with_metadata(json!({"execution_receipt": bad}))
|
||||
));
|
||||
assert!(context.tool_execution_receipt.is_none());
|
||||
}
|
||||
let oversized = HookContext {
|
||||
tool_execution_receipt: Some("x".repeat(super::HOOK_EXECUTION_RECEIPT_MAX_BYTES + 1)),
|
||||
..HookContext::new()
|
||||
};
|
||||
assert!(
|
||||
!oversized
|
||||
.to_env_vars()
|
||||
.contains_key("DEEPSEEK_TOOL_EXECUTION_RECEIPT")
|
||||
);
|
||||
assert!(
|
||||
oversized
|
||||
.bounded_for_observer()
|
||||
.tool_execution_receipt
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observer_context_is_bounded_before_enqueue() {
|
||||
let huge = "用户".repeat(20_000);
|
||||
|
||||
@@ -4763,6 +4763,7 @@ async fn execute_foreground_via_background(
|
||||
extra_env: HashMap<String, String>,
|
||||
direct_argv: bool,
|
||||
timeout_bounds_ms: (u64, u64),
|
||||
receipt_identity: &mut Option<ShellExecutionIdentity>,
|
||||
) -> Result<ShellResult> {
|
||||
let timeout_ms =
|
||||
timeout_ms.map(|timeout| timeout.clamp(timeout_bounds_ms.0, timeout_bounds_ms.1));
|
||||
@@ -4798,11 +4799,23 @@ async fn execute_foreground_via_background(
|
||||
.ok_or_else(|| anyhow!("foreground shell did not return a process id"))?;
|
||||
// Classify before releasing the manager lock: even an immediately
|
||||
// completed foreground command must never look like background work.
|
||||
manager
|
||||
let process = manager
|
||||
.processes
|
||||
.get_mut(&task_id)
|
||||
.ok_or_else(|| anyhow!("foreground shell {task_id} is not tracked"))?
|
||||
.background = false;
|
||||
.ok_or_else(|| anyhow!("foreground shell {task_id} is not tracked"))?;
|
||||
process.background = false;
|
||||
// #6689: the receipt identity is what the manager recorded for this
|
||||
// spawn — the admitted command and the directory handed to the OS —
|
||||
// never the before-hook request. Only pipe-backed, unsandboxed local
|
||||
// runs qualify: a PTY, the hardened read-only argv rewrite, an OS
|
||||
// sandbox wrapper, and Windows shell prefixes all change what the
|
||||
// process actually executes relative to this string.
|
||||
if !cfg!(windows) && !tty && !direct_argv && !spawned.sandboxed && !spawned.sandbox_denied {
|
||||
*receipt_identity = Some(ShellExecutionIdentity {
|
||||
command: process.command.clone(),
|
||||
cwd: process.working_dir.clone(),
|
||||
});
|
||||
}
|
||||
task_id
|
||||
};
|
||||
let mut foreground = ForegroundShellGuard {
|
||||
@@ -4933,6 +4946,110 @@ impl Drop for ForegroundShellGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// The admitted command and working directory a foreground spawn recorded,
|
||||
/// for the `tool_call_after` execution receipt (#6689).
|
||||
struct ShellExecutionIdentity {
|
||||
command: String,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
/// Largest command or working directory a receipt carries. Identities are
|
||||
/// exact or absent, never truncated.
|
||||
const EXECUTION_RECEIPT_IDENTITY_MAX_BYTES: usize = 8 * 1024;
|
||||
|
||||
/// Starting per-stream output preview in an execution receipt. Halved until
|
||||
/// the serialized receipt fits `HOOK_EXECUTION_RECEIPT_MAX_BYTES`.
|
||||
const EXECUTION_RECEIPT_PREVIEW_MAX_BYTES: usize = 8 * 1024;
|
||||
|
||||
/// Keep both ends of an output stream — the first lines and the final
|
||||
/// diagnostic — cut on UTF-8 boundaries with a visible marker.
|
||||
fn execution_receipt_preview(text: &str, budget: usize) -> (String, bool) {
|
||||
if text.len() <= budget {
|
||||
return (text.to_owned(), false);
|
||||
}
|
||||
let mut head = budget / 2;
|
||||
while !text.is_char_boundary(head) {
|
||||
head -= 1;
|
||||
}
|
||||
let mut tail = text.len() - budget / 2;
|
||||
while !text.is_char_boundary(tail) {
|
||||
tail += 1;
|
||||
}
|
||||
(
|
||||
format!(
|
||||
"{}\n[receipt preview truncated]\n{}",
|
||||
&text[..head],
|
||||
&text[tail..]
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the schema-1 execution receipt for a settled foreground shell run
|
||||
/// (#6689), exported to `tool_call_after` hooks as
|
||||
/// `DEEPSEEK_TOOL_EXECUTION_RECEIPT`.
|
||||
///
|
||||
/// Returns `None` — absence, which implies neither success nor failure — for
|
||||
/// a run still in flight, a sandboxed run, or an identity that is not exact:
|
||||
/// empty, over the identity bound, containing NUL, or a relative or non-UTF-8
|
||||
/// directory. `output_kind` is `"combined"` when stdout and stderr shared one
|
||||
/// pipe, so `stdout` holds the combined preview.
|
||||
fn shell_execution_receipt(
|
||||
identity: &ShellExecutionIdentity,
|
||||
result: &ShellResult,
|
||||
output_kind: &'static str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let command = identity.command.as_str();
|
||||
let cwd = identity.cwd.to_str()?;
|
||||
if command.is_empty()
|
||||
|| command.len() > EXECUTION_RECEIPT_IDENTITY_MAX_BYTES
|
||||
|| command.contains('\0')
|
||||
|| cwd.len() > EXECUTION_RECEIPT_IDENTITY_MAX_BYTES
|
||||
|| cwd.contains('\0')
|
||||
|| !identity.cwd.is_absolute()
|
||||
|| result.sandboxed
|
||||
|| result.sandbox_denied
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// A nonzero exit is still a completed run; `interrupted` is a signal,
|
||||
// kill, cancel, or timeout. The exit code is only ever the observed one.
|
||||
let state = match result.status {
|
||||
ShellStatus::Completed => "completed",
|
||||
ShellStatus::Failed if result.exit_code.is_some() => "completed",
|
||||
ShellStatus::Failed | ShellStatus::Killed | ShellStatus::TimedOut => "interrupted",
|
||||
ShellStatus::Running => return None,
|
||||
};
|
||||
let mut budget = EXECUTION_RECEIPT_PREVIEW_MAX_BYTES;
|
||||
loop {
|
||||
let (stdout, stdout_clipped) = execution_receipt_preview(&result.stdout, budget);
|
||||
let (stderr, stderr_clipped) = execution_receipt_preview(&result.stderr, budget);
|
||||
let receipt = json!({
|
||||
"schema_version": 1,
|
||||
"command": command,
|
||||
"cwd": cwd,
|
||||
"state": state,
|
||||
"scope": "local",
|
||||
"exit_code": result.exit_code,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"stdout_truncated": result.stdout_truncated || stdout_clipped,
|
||||
"stderr_truncated": result.stderr_truncated || stderr_clipped,
|
||||
"output_kind": output_kind,
|
||||
});
|
||||
// The bound is on the serialized form, so JSON escaping counts.
|
||||
if serde_json::to_vec(&receipt).ok()?.len()
|
||||
<= crate::hooks::HOOK_EXECUTION_RECEIPT_MAX_BYTES
|
||||
{
|
||||
return Some(receipt);
|
||||
}
|
||||
if budget == 0 {
|
||||
return None;
|
||||
}
|
||||
budget /= 2;
|
||||
}
|
||||
}
|
||||
|
||||
const BASH_MAX_TIMEOUT_MS: u64 = i32::MAX as u64;
|
||||
|
||||
/// Initial cadence for foreground-via-background completion polling. Fast
|
||||
@@ -5001,6 +5118,7 @@ fn finish_contract_bash_result(
|
||||
result: ShellResult,
|
||||
timeout_ms: Option<u64>,
|
||||
context: &ToolContext,
|
||||
execution_receipt: Option<serde_json::Value>,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let sandbox_denied_hint = shell_sandbox_denied_hint(context, &result);
|
||||
let mut output = result.stdout.clone();
|
||||
@@ -5012,12 +5130,15 @@ fn finish_contract_bash_result(
|
||||
format!("{hint}\n\n{output}")
|
||||
};
|
||||
}
|
||||
let metadata = json!({
|
||||
let mut metadata = json!({
|
||||
"evidence_routing": "inline", "exit_code": result.exit_code,
|
||||
"status": format!("{:?}", result.status), "duration_ms": result.duration_ms,
|
||||
"sandboxed": result.sandboxed, "sandbox_type": result.sandbox_type,
|
||||
"task_id": result.task_id, "backgrounded": result.status == ShellStatus::Running,
|
||||
});
|
||||
if let Some(receipt) = execution_receipt {
|
||||
metadata["execution_receipt"] = receipt;
|
||||
}
|
||||
if result.status == ShellStatus::Running {
|
||||
let task_id = result.task_id.as_deref().unwrap_or("unknown");
|
||||
let partial = (!output.is_empty()).then(|| format!("\n\nOutput so far:\n{output}"));
|
||||
@@ -5928,6 +6049,7 @@ impl ToolSpec for BashTool {
|
||||
}
|
||||
|
||||
let mut lifecycle_warning = None;
|
||||
let mut receipt_identity = None;
|
||||
let result = if interactive {
|
||||
let mut manager = context
|
||||
.shell_manager
|
||||
@@ -6011,6 +6133,7 @@ impl ToolSpec for BashTool {
|
||||
} else {
|
||||
(1_000, 600_000)
|
||||
},
|
||||
&mut receipt_identity,
|
||||
)
|
||||
.await
|
||||
};
|
||||
@@ -6033,8 +6156,26 @@ impl ToolSpec for BashTool {
|
||||
.cancel_token
|
||||
.as_ref()
|
||||
.is_some_and(|token| token.is_cancelled());
|
||||
// Lowercase `bash` joins stdout and stderr on one pipe, so
|
||||
// its preview is combined output, not stdout.
|
||||
let execution_receipt = receipt_identity.as_ref().and_then(|identity| {
|
||||
shell_execution_receipt(
|
||||
identity,
|
||||
&result,
|
||||
if self.optional_timeout {
|
||||
"combined"
|
||||
} else {
|
||||
"separate"
|
||||
},
|
||||
)
|
||||
});
|
||||
if self.optional_timeout {
|
||||
return finish_contract_bash_result(result, timeout_ms, context);
|
||||
return finish_contract_bash_result(
|
||||
result,
|
||||
timeout_ms,
|
||||
context,
|
||||
execution_receipt,
|
||||
);
|
||||
}
|
||||
let task_id_str = result.task_id.clone().unwrap_or_default();
|
||||
let stdout_summary = summarize_output(&result.stdout);
|
||||
@@ -6165,6 +6306,9 @@ impl ToolSpec for BashTool {
|
||||
}),
|
||||
}),
|
||||
});
|
||||
if let Some(receipt) = execution_receipt {
|
||||
metadata["execution_receipt"] = receipt;
|
||||
}
|
||||
metadata["backgrounded"] = json!(background || backgrounded_foreground);
|
||||
if persist {
|
||||
metadata["persist_requested"] = json!(true);
|
||||
|
||||
@@ -263,6 +263,7 @@ fn contract_bash_nonzero_is_an_error_with_status_after_output() {
|
||||
},
|
||||
None,
|
||||
&ToolContext::new("."),
|
||||
None,
|
||||
)
|
||||
.expect_err("nonzero must be a failed tool call");
|
||||
assert!(
|
||||
@@ -292,6 +293,160 @@ async fn lowercase_bash_returns_one_ordered_stream() {
|
||||
assert_eq!(result.content, "out-1err-2out-3");
|
||||
}
|
||||
|
||||
/// #6689: the receipt is exact-or-absent, fits the hook bound after JSON
|
||||
/// escaping, and reports how the run ended without inventing an exit code.
|
||||
#[test]
|
||||
fn execution_receipt_is_bounded_and_reports_truthful_state() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let identity = |command: &str, cwd: &Path| ShellExecutionIdentity {
|
||||
command: command.to_string(),
|
||||
cwd: cwd.to_path_buf(),
|
||||
};
|
||||
let cwd = tmp.path().canonicalize().unwrap();
|
||||
let mut result = failed_network_shell_result(
|
||||
&"\u{1f40b}\n\"\u{1}".repeat(12_000),
|
||||
&"err\n".repeat(12_000),
|
||||
);
|
||||
result.sandboxed = false;
|
||||
result.sandbox_type = None;
|
||||
result.stdout_truncated = true;
|
||||
result.exit_code = None;
|
||||
result.status = ShellStatus::Killed;
|
||||
|
||||
let receipt = shell_execution_receipt(&identity("printf hi", &cwd), &result, "separate")
|
||||
.expect("receipt");
|
||||
let encoded = serde_json::to_string(&receipt).unwrap();
|
||||
assert!(encoded.len() <= crate::hooks::HOOK_EXECUTION_RECEIPT_MAX_BYTES);
|
||||
assert_eq!(serde_json::from_str::<Value>(&encoded).unwrap(), receipt);
|
||||
assert_eq!(receipt["schema_version"], 1);
|
||||
assert_eq!(receipt["command"], "printf hi");
|
||||
assert_eq!(receipt["cwd"], cwd.to_str().unwrap());
|
||||
assert_eq!(receipt["state"], "interrupted");
|
||||
assert!(receipt["exit_code"].is_null());
|
||||
assert_eq!(receipt["stdout_truncated"], true);
|
||||
assert_eq!(receipt["stderr_truncated"], true);
|
||||
assert!(
|
||||
receipt["stderr"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("[receipt preview truncated]")
|
||||
);
|
||||
|
||||
// A nonzero exit is a completed run, and a 64-bit code survives.
|
||||
result.status = ShellStatus::Failed;
|
||||
result.exit_code = Some(3_221_225_477);
|
||||
let receipt = shell_execution_receipt(&identity("x", &cwd), &result, "combined").unwrap();
|
||||
assert_eq!(receipt["state"], "completed");
|
||||
assert_eq!(receipt["exit_code"], 3_221_225_477_i64);
|
||||
assert_eq!(receipt["output_kind"], "combined");
|
||||
|
||||
result.status = ShellStatus::TimedOut;
|
||||
result.exit_code = None;
|
||||
let receipt = shell_execution_receipt(&identity("x", &cwd), &result, "separate").unwrap();
|
||||
assert_eq!(receipt["state"], "interrupted");
|
||||
|
||||
// Absent, never truncated or guessed.
|
||||
result.status = ShellStatus::Running;
|
||||
assert!(shell_execution_receipt(&identity("x", &cwd), &result, "separate").is_none());
|
||||
result.status = ShellStatus::Completed;
|
||||
for command in ["", "bad\0command"] {
|
||||
assert!(shell_execution_receipt(&identity(command, &cwd), &result, "separate").is_none());
|
||||
}
|
||||
let long = "x".repeat(EXECUTION_RECEIPT_IDENTITY_MAX_BYTES + 1);
|
||||
assert!(shell_execution_receipt(&identity(&long, &cwd), &result, "separate").is_none());
|
||||
let long_cwd = PathBuf::from(format!("/{long}"));
|
||||
assert!(shell_execution_receipt(&identity("x", &long_cwd), &result, "separate").is_none());
|
||||
assert!(
|
||||
shell_execution_receipt(&identity("x", Path::new("relative")), &result, "separate")
|
||||
.is_none()
|
||||
);
|
||||
result.sandboxed = true;
|
||||
assert!(shell_execution_receipt(&identity("x", &cwd), &result, "separate").is_none());
|
||||
}
|
||||
|
||||
/// #6689: a settled foreground run records the command and directory the
|
||||
/// process manager spawned, on success and on failure, and background runs
|
||||
/// carry no receipt.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn foreground_shell_results_carry_the_spawned_execution_receipt() {
|
||||
let tmp = tempdir().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("child")).unwrap();
|
||||
let mut context = ToolContext::new(tmp.path().to_path_buf())
|
||||
.with_elevated_sandbox_policy(ExecutionSandboxPolicy::DangerFullAccess);
|
||||
context.auto_approve = true;
|
||||
let child = tmp.path().join("child").canonicalize().unwrap();
|
||||
let receipt_cwd = |receipt: &Value| {
|
||||
Path::new(receipt["cwd"].as_str().expect("cwd"))
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let tool = BashTool::new("Bash");
|
||||
let command = "printf effective; printf diagnostic >&2; exit 7";
|
||||
let result = tool
|
||||
.execute(json!({"command": command, "cwd": "child"}), &context)
|
||||
.await
|
||||
.unwrap();
|
||||
let receipt = &result.metadata.as_ref().unwrap()["execution_receipt"];
|
||||
assert_eq!(receipt["command"], command);
|
||||
assert!(Path::new(receipt["cwd"].as_str().unwrap()).is_absolute());
|
||||
assert_eq!(receipt_cwd(receipt), child);
|
||||
assert_eq!(receipt["stdout"], "effective");
|
||||
assert_eq!(receipt["stderr"], "diagnostic");
|
||||
assert_eq!(receipt["exit_code"], 7);
|
||||
assert_eq!(receipt["state"], "completed");
|
||||
assert_eq!(receipt["output_kind"], "separate");
|
||||
|
||||
let interrupted = tool
|
||||
.execute(json!({"command": "kill -TERM $$"}), &context)
|
||||
.await
|
||||
.unwrap();
|
||||
let receipt = &interrupted.metadata.as_ref().unwrap()["execution_receipt"];
|
||||
assert_eq!(receipt["state"], "interrupted");
|
||||
assert!(receipt["exit_code"].is_null());
|
||||
|
||||
let background = tool
|
||||
.execute(
|
||||
json!({"command": "printf background", "background": true}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
background
|
||||
.metadata
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get("execution_receipt")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// Lowercase `bash` shares one pipe: the preview is combined output, and a
|
||||
// failing command's error still carries the receipt for hooks.
|
||||
let result = LowercaseBashTool
|
||||
.execute(
|
||||
json!({"command": "printf merged; printf diagnostic >&2"}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let receipt = &result.metadata.as_ref().unwrap()["execution_receipt"];
|
||||
assert_eq!(receipt["output_kind"], "combined");
|
||||
assert_eq!(receipt["stdout"], "mergeddiagnostic");
|
||||
assert_eq!(receipt["stderr"], "");
|
||||
assert_eq!(receipt["state"], "completed");
|
||||
let error = LowercaseBashTool
|
||||
.execute(json!({"command": "printf partial; exit 3"}), &context)
|
||||
.await
|
||||
.expect_err("nonzero exit is a failed bash call");
|
||||
let receipt = &error.metadata().expect("failure metadata")["execution_receipt"];
|
||||
assert_eq!(receipt["command"], "printf partial; exit 3");
|
||||
assert_eq!(receipt["exit_code"], 3);
|
||||
assert_eq!(receipt["state"], "completed");
|
||||
assert_eq!(receipt_cwd(receipt), tmp.path().canonicalize().unwrap());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn lowercase_bash_keeps_raw_command_under_readonly_policy() {
|
||||
@@ -2538,7 +2693,7 @@ fn contract_bash_denial_surfaces_the_escalation_shape() {
|
||||
let mut result = failed_network_shell_result("", "Operation not permitted");
|
||||
result.sandbox_denied = true;
|
||||
|
||||
let error = finish_contract_bash_result(result, None, &ctx)
|
||||
let error = finish_contract_bash_result(result, None, &ctx, None)
|
||||
.expect_err("sandbox denial is a failed call");
|
||||
|
||||
assert!(
|
||||
|
||||
@@ -311,8 +311,47 @@ rebrand.
|
||||
| `DEEPSEEK_TOOL_SUCCESS` | `tool_call_after`, `on_error` (tool failures) | `true` / `false` |
|
||||
| `DEEPSEEK_TOOL_EXIT_CODE` | `tool_call_after` and `on_error` **when the tool reported one** | absent otherwise — never synthesized; set for a failing command as well as a passing one; 64-bit, so Windows crash codes such as `3221225477` survive |
|
||||
| `DEEPSEEK_TOOL_STATUS` | `tool_call_after` and `on_error` **when a shell tool reported one** | `completed`, `failed`, `timed_out`, `killed`, or `running` (moved to the background); absent for other tools |
|
||||
| `DEEPSEEK_TOOL_EXECUTION_RECEIPT` | `tool_call_after` and `on_error` **for a settled, local, foreground shell run** | complete JSON, at most 32 KiB, or absent; see [Execution receipt](#execution-receipt) |
|
||||
| `DEEPSEEK_SESSION_COST` | when cost is supplied | USD, six decimal places |
|
||||
|
||||
### Execution receipt
|
||||
|
||||
`DEEPSEEK_TOOL_EXECUTION_RECEIPT` says what a shell tool (`bash`, `Bash`,
|
||||
`exec_shell`) actually ran. The before-hook input is not the same thing: a
|
||||
`tool_call_before` hook can rewrite it. The receipt is built from what the
|
||||
process manager recorded when it spawned the process, after admission and
|
||||
any rewrite.
|
||||
|
||||
```json
|
||||
{"schema_version":1,"command":"printf hello","cwd":"/absolute/workspace","state":"completed","scope":"local","exit_code":0,"stdout":"hello","stderr":"","stdout_truncated":false,"stderr_truncated":false,"output_kind":"separate"}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `command` | the admitted shell source handed to the shell, not the shell executable or its argv wrapper |
|
||||
| `cwd` | the absolute directory the process started in, as passed to the OS (symlinks are not resolved) |
|
||||
| `state` | `completed` for an observed exit, including a nonzero one; `interrupted` for a signal, kill, cancel, or timeout |
|
||||
| `scope` | always `local` in schema 1 |
|
||||
| `exit_code` | the observed integer, or `null`; never synthesized from `state` |
|
||||
| `stdout`, `stderr` | output previews; a long stream keeps its first and last bytes around a `[receipt preview truncated]` marker |
|
||||
| `stdout_truncated`, `stderr_truncated` | `true` when the tool's own output capture or the preview dropped bytes |
|
||||
| `output_kind` | `separate` for `Bash` / `exec_shell`; `combined` for lowercase `bash`, whose stdout and stderr share one pipe — `stdout` then holds the combined preview and `stderr` is empty |
|
||||
|
||||
The rules are conservative:
|
||||
|
||||
- **Exact or absent.** `command` and `cwd` are never truncated. If either is
|
||||
over 8 KiB, contains NUL, or the directory is relative or not UTF-8, the
|
||||
receipt is left out. Previews shrink until the serialized JSON fits 32 KiB;
|
||||
if it still cannot fit, the receipt is left out rather than cut.
|
||||
- **Absence means nothing.** It implies neither success nor failure.
|
||||
- **Scope.** Only a settled, pipe-backed, unsandboxed, local foreground run
|
||||
gets a receipt. Background launches, a foreground run moved to `/jobs`,
|
||||
PTY (`tty` / `combined_output`) and interactive sessions, OS-sandboxed and
|
||||
external-backend execution, the read-only shell's hardened argv, Windows,
|
||||
and calls refused before execution have none.
|
||||
- It is set for a failed run as well as a passing one, so `on_error` for a
|
||||
failed shell call carries it too. Every other variable is unchanged.
|
||||
|
||||
**Mode-spelling note.** UI-fired events (`session_start`, `session_end`,
|
||||
`message_submit`, `tool_call_after`, `mode_change`, `on_error`, `turn_end`,
|
||||
`subagent_*`, `session_busy`, `session_idle`, `session_error`, `waiting_for_user`)
|
||||
|
||||
@@ -163,8 +163,35 @@ Observer 并**不**意味着无副作用。observer hook 是以你的凭据运
|
||||
| `DEEPSEEK_TOOL_SUCCESS` | `tool_call_after`、`on_error`(工具失败) | `true` / `false` |
|
||||
| `DEEPSEEK_TOOL_EXIT_CODE` | `tool_call_after` 和 `on_error` **当工具报告了退出码时** | 否则不存在——绝不合成;命令失败时同样设置;64 位,因此 `3221225477` 这样的 Windows 崩溃码能完好保留 |
|
||||
| `DEEPSEEK_TOOL_STATUS` | `tool_call_after` 和 `on_error` **当 shell 工具报告了状态时** | `completed`、`failed`、`timed_out`、`killed` 或 `running`(已转入后台);其他工具不存在 |
|
||||
| `DEEPSEEK_TOOL_EXECUTION_RECEIPT` | `tool_call_after` 和 `on_error` **仅限已结束的本地前台 shell 运行** | 完整 JSON,最多 32 KiB,否则不存在;见下方“执行回执” |
|
||||
| `DEEPSEEK_SESSION_COST` | 提供成本时 | USD,六位小数 |
|
||||
|
||||
### 执行回执
|
||||
|
||||
`DEEPSEEK_TOOL_EXECUTION_RECEIPT` 说明 shell 工具(`bash`、`Bash`、`exec_shell`)实际运行了什么。before-hook 的输入不等于实际执行的内容:`tool_call_before` hook 可以改写它。回执取自进程管理器在准入与改写之后启动进程时记录的内容。
|
||||
|
||||
```json
|
||||
{"schema_version":1,"command":"printf hello","cwd":"/absolute/workspace","state":"completed","scope":"local","exit_code":0,"stdout":"hello","stderr":"","stdout_truncated":false,"stderr_truncated":false,"output_kind":"separate"}
|
||||
```
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `command` | 交给 shell 的已准入命令源码,而不是 shell 可执行文件或其 argv 包装 |
|
||||
| `cwd` | 进程启动时所在的绝对目录,即传给操作系统的路径(不解析符号链接) |
|
||||
| `state` | 观察到退出(包括非零退出)为 `completed`;信号、kill、取消或超时为 `interrupted` |
|
||||
| `scope` | schema 1 中始终为 `local` |
|
||||
| `exit_code` | 观察到的整数,或 `null`;绝不根据 `state` 合成 |
|
||||
| `stdout`、`stderr` | 输出预览;过长的流保留首尾字节,中间以 `[receipt preview truncated]` 标记 |
|
||||
| `stdout_truncated`、`stderr_truncated` | 工具自身的输出捕获或预览丢弃了字节时为 `true` |
|
||||
| `output_kind` | `Bash` / `exec_shell` 为 `separate`;小写 `bash` 的 stdout 与 stderr 共用一个管道,为 `combined`——此时 `stdout` 是合并后的预览,`stderr` 为空 |
|
||||
|
||||
规则是保守的:
|
||||
|
||||
- **要么精确,要么不存在。** `command` 和 `cwd` 绝不截断。任一超过 8 KiB、包含 NUL,或目录是相对路径或非 UTF-8 时,不导出回执。预览会缩短直到序列化后的 JSON 不超过 32 KiB;仍然放不下时不导出回执,而不是截断。
|
||||
- **不存在不代表任何结果。** 既不意味着成功,也不意味着失败。
|
||||
- **范围。** 只有已结束、基于管道、未沙箱化的本地前台运行才有回执。后台启动、转入 `/jobs` 的前台运行、PTY(`tty` / `combined_output`)与交互会话、OS 沙箱与外部后端执行、只读 shell 的加固 argv、Windows,以及执行前就被拒绝的调用都没有回执。
|
||||
- 失败的运行与成功的运行同样设置,因此 shell 调用失败时的 `on_error` 也带有它。其他变量均不变。
|
||||
|
||||
**模式拼写说明。** UI 触发的事件(`session_start`、`session_end`、`message_submit`、`tool_call_after`、`mode_change`、`on_error`、`turn_end`、`subagent_*`)会将 `DEEPSEEK_MODE` 设为 UI 标签——`ACT`、`PLAN`、`OPERATE`。`tool_call_before` 在引擎内部触发,并使用引擎自己的模式拼写(`Agent`、`Plan`、`Operate`)。`mode` 条件不区分大小写比较,因此 `{ type = "mode", mode = "plan" }` 两者都能匹配,但精确字符串匹配 `$DEEPSEEK_MODE` 的 hook 应同时接受两种拼写。
|
||||
|
||||
**`shell_env` 是受限的那个。** 它只接收 `DEEPSEEK_TOOL_NAME` 和 `DEEPSEEK_TOOL_ARGS`——没有会话 id、工作区、模型或模式。因此,`shell_env` hook 上的 `{ type = "mode", … }` 条件会在加载时被拒绝;请改用 `tool_name` 或 `tool_category` 来限定作用域。
|
||||
|
||||
Reference in new issue
Block a user