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:
committed 2026-09-28 20:22:27 -07:00
1 parent b36a347742
commit b965cd7ddd
6 files changed
+492 -9

No files matched your search

+3 -3
View File
@@ -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::{
+118
View File
@@ -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);
+149 -5
View File
@@ -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);
+156 -1
View File
@@ -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!(
+39
View File
@@ -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`)
+27
View File
@@ -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` 来限定作用域。