Keep shell hook execution receipts bound to the current spawn

Resolve the receipt cwd asynchronously before process creation and pass that
same resolved path to the process manager and OS. A command that retargets
the workspace symlink cannot change the reported starting directory.

Clear inherited DEEPSEEK_TOOL_EXECUTION_RECEIPT before overlaying the current
hook context in both foreground and background hook launches. Clarify that
bounded previews show retained tool output, which may already omit earlier
process output. Preserve the original PR and contributor attribution.

Validation on the host using the existing contributor target and hermetic
test HOME:
- cargo test --locked -p codewhale-tui --lib -- execution_receipt
  runtime_tool_completion runtime_shell_completion --test-threads=1:
  7 passed, 0 failed (including symlink-retarget and six actual hook children
  covering absent, oversized and current receipts in both launch modes).
- npm test: 636 passed, 0 failed.
- npm run check:web: passed.
- rustfmt and git diff --check: passed.

The child sandbox's earlier npm and web attempts failed on loopback EPERM
and api.github.com DNS; the host reruns above completed successfully.
No provider, deployment or release qualification is claimed.
This commit is contained in:
Hunter B committed 2026-09-29 02:56:57 -07:00
1 parent cab3f446ee
commit 69eebf8ae8
5 files changed
+134 -22

No files matched your search

+58
View File
@@ -1463,6 +1463,9 @@ impl HookExecutor {
// raw_arg: cmd.exe does not parse the CRT-style \" escapes that
// Command::arg would insert, so pass the command line verbatim.
cmd.arg("/C").raw_arg(command);
// Only this call's context may supply a receipt. In particular,
// a Codewhale launched from another hook must not inherit one.
cmd.env_remove("DEEPSEEK_TOOL_EXECUTION_RECEIPT");
cmd
}
#[cfg(not(windows))]
@@ -1474,6 +1477,9 @@ impl HookExecutor {
use std::os::unix::process::CommandExt as _;
cmd.process_group(0);
}
// Only this call's context may supply a receipt. In particular,
// a Codewhale launched from another hook must not inherit one.
cmd.env_remove("DEEPSEEK_TOOL_EXECUTION_RECEIPT");
cmd
}
}
@@ -5735,6 +5741,58 @@ command = "echo project"
);
}
/// An absent receipt must be absent in the actual child environment,
/// even when a nested Codewhale inherited an outer hook's receipt.
#[cfg(unix)]
#[test]
fn execution_receipt_never_inherits_another_calls_environment() {
let _env = lock_test_env();
let _stale = EnvVarGuard::set("DEEPSEEK_TOOL_EXECUTION_RECEIPT", "stale-outer-receipt");
let current = r#"{"schema_version":1,"command":"current call"}"#;
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("receipt-env.txt");
let command = write_hook_script(
&dir,
"capture_receipt_env.sh",
&format!(
"#!/bin/sh\nprintf '%s' \"${{DEEPSEEK_TOOL_EXECUTION_RECEIPT-unset}}\" > {}\n",
out.display()
),
);
for background in [false, true] {
let mut hook = Hook::new(HookEvent::ToolCallAfter, &command);
hook.background = background;
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![hook],
..HooksConfig::default()
},
dir.path().to_path_buf(),
);
for (receipt, expected) in [
(None, "unset"),
(
Some("x".repeat(HOOK_EXECUTION_RECEIPT_MAX_BYTES + 1)),
"unset",
),
(Some(current.to_string()), current),
] {
if out.exists() {
std::fs::remove_file(&out).unwrap();
}
let context = HookContext {
tool_execution_receipt: receipt,
..HookContext::new()
};
let results = executor.execute(HookEvent::ToolCallAfter, &context);
assert_eq!(results.len(), 1);
assert!(results[0].success);
assert_eq!(wait_for_captured_output(&out), expected);
}
}
}
#[test]
fn observer_context_is_bounded_before_enqueue() {
let huge = "用户".repeat(20_000);
+21 -13
View File
@@ -4769,11 +4769,29 @@ async fn execute_foreground_via_background(
extra_env: HashMap<String, String>,
direct_argv: bool,
timeout_bounds_ms: (u64, u64),
wants_receipt: bool,
receipt_identity: &mut Option<ShellExecutionIdentity>,
) -> Result<ShellResult> {
let timeout_ms =
timeout_ms.map(|timeout| timeout.clamp(timeout_bounds_ms.0, timeout_bounds_ms.1));
let spawn_timeout_ms = timeout_ms.unwrap_or(timeout_bounds_ms.1);
// Freeze the receipt's directory before execution and hand that same
// resolved spelling to the OS. Looking up the original symlink after
// the command runs can name a different directory than the one it used.
// Resolution is asynchronous; failure leaves the ordinary execution
// path intact but cannot produce an exact receipt.
let receipt_cwd = if wants_receipt {
match working_dir.as_deref() {
Some(cwd) => tokio::fs::canonicalize(cwd)
.await
.ok()
.and_then(|path| path.into_os_string().into_string().ok()),
None => None,
}
} else {
None
};
let working_dir = receipt_cwd.clone().or(working_dir);
let task_id = {
let mut manager = context
.shell_manager
@@ -4818,7 +4836,8 @@ async fn execute_foreground_via_background(
// wrapper, Windows shell prefixes, and a PowerShell dispatcher (even
// `$SHELL=pwsh` on Unix, which wraps the source or runs it from a temp
// `-File`) all change what the process executes relative to this string.
if !cfg!(windows)
if receipt_cwd.is_some()
&& !cfg!(windows)
&& !tty
&& !direct_argv
&& !spawned.sandboxed
@@ -6166,22 +6185,11 @@ impl ToolSpec for BashTool {
} else {
(1_000, 600_000)
},
wants_receipt,
&mut receipt_identity,
)
.await
};
// One spelling per directory. An explicit `cwd` reaches the spawn
// already canonicalized by `resolve_path`; the default workspace
// arrives as the session opened it, possibly through a symlink.
// Resolve both the same way, off the runtime thread. A directory that
// no longer resolves when the run settles gets no receipt.
let receipt_identity = match receipt_identity.filter(|_| wants_receipt) {
Some(identity) => tokio::fs::canonicalize(&identity.cwd)
.await
.ok()
.map(|cwd| ShellExecutionIdentity { cwd, ..identity }),
None => None,
};
match result {
Ok(result) => {
+45
View File
@@ -492,6 +492,51 @@ async fn foreground_shell_results_carry_the_spawned_execution_receipt() {
assert_eq!(receipt["cwd"], real_str);
}
/// A post-run lookup of a retargeted workspace symlink would falsely name
/// the replacement directory. The receipt must keep the actual spawn path.
#[cfg(unix)]
#[tokio::test]
async fn execution_receipt_keeps_spawn_cwd_when_the_command_retargets_the_workspace() {
let tmp = tempdir().unwrap();
let real = tmp.path().join("real");
let other = tmp.path().join("other");
std::fs::create_dir(&real).unwrap();
std::fs::create_dir(&other).unwrap();
let workspace = tmp.path().join("link");
std::os::unix::fs::symlink(&real, &workspace).unwrap();
let mut context = ToolContext::new(workspace.clone())
.with_elevated_sandbox_policy(ExecutionSandboxPolicy::DangerFullAccess);
context.auto_approve = true;
context.runtime.hook_executor = Some(std::sync::Arc::new(crate::hooks::HookExecutor::new(
crate::hooks::HooksConfig {
enabled: true,
hooks: vec![crate::hooks::Hook::new(
crate::hooks::HookEvent::ToolCallAfter,
"true",
)],
..crate::hooks::HooksConfig::default()
},
workspace.clone(),
)));
let command = "pwd -P; rm ../link; ln -s other ../link; pwd -P";
let result = BashTool::new("Bash")
.execute(json!({"command": command}), &context)
.await
.unwrap();
assert!(result.success);
let receipt = &result.metadata.as_ref().unwrap()["execution_receipt"];
let actual = real.canonicalize().unwrap();
let actual = actual.to_str().unwrap();
assert_eq!(receipt["command"], command);
assert_eq!(receipt["cwd"], actual);
assert_eq!(receipt["stdout"], format!("{actual}\n{actual}\n"));
assert_eq!(receipt["exit_code"], 0);
assert_eq!(
workspace.canonicalize().unwrap(),
other.canonicalize().unwrap()
);
}
#[cfg(unix)]
#[tokio::test]
async fn lowercase_bash_keeps_raw_command_under_readonly_policy() {
+6 -5
View File
@@ -329,23 +329,24 @@ any rewrite.
| Field | Meaning |
| --- | --- |
| `command` | the admitted shell source handed to the shell, not the shell executable or its argv wrapper |
| `cwd` | the canonical absolute path of the directory the process started in: symlinks are resolved, so a directory has one spelling whether or not the call passed `cwd`; it is resolved when the run settles |
| `cwd` | the canonical absolute path of the directory the process started in: symlinks are resolved, so a directory has one spelling whether or not the call passed `cwd`; it is resolved before spawn and that same path is handed to the OS |
| `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`, `stderr` | previews of the tool's retained output, which may already omit early process output; long previews keep their 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, not UTF-8, or no
longer resolves, the receipt is left out. So is a run whose end the shell
over 8 KiB, contains NUL, or the directory is relative, not UTF-8, or cannot
resolve before spawn, the receipt is left out. So is a run whose end the shell
tool could not observe (the OS wait itself failed): its state is unknown,
and the receipt does not guess it. 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.
- **Absence means nothing.** It implies neither success nor failure. An inherited
`DEEPSEEK_TOOL_EXECUTION_RECEIPT` is cleared before applying the current call's context.
- **Scope.** A receipt is built only while a `tool_call_after` or `on_error`
hook is configured, and only for a settled, pipe-backed, unsandboxed, local
foreground run. Background launches, a foreground run moved to `/jobs`,
+4 -4
View File
@@ -177,18 +177,18 @@ Observer 并**不**意味着无副作用。observer hook 是以你的凭据运
| 字段 | 含义 |
| --- | --- |
| `command` | 交给 shell 的已准入命令源码,而不是 shell 可执行文件或其 argv 包装 |
| `cwd` | 进程启动时所在目录的规范绝对路径:解析符号链接,因此无论调用是否传入 `cwd`,同一目录只有一种写法;在运行结束时解析 |
| `cwd` | 进程启动时所在目录的规范绝对路径:解析符号链接,因此无论调用是否传入 `cwd`,同一目录只有一种写法;在启动前解析,并将同一路径交给操作系统 |
| `state` | 观察到退出(包括非零退出)为 `completed`;信号、kill、取消或超时为 `interrupted` |
| `scope` | schema 1 中始终为 `local` |
| `exit_code` | 观察到的整数,或 `null`;绝不根据 `state` 合成 |
| `stdout`、`stderr` | 输出预览;过长的流保留首尾字节,中间以 `[receipt preview truncated]` 标记 |
| `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 或已无法解析时,不导出回执。shell 工具无法观察到运行如何结束(操作系统的 wait 调用本身失败)时同样不导出:其状态未知,回执不做猜测。预览会缩短直到序列化后的 JSON 不超过 32 KiB;仍然放不下时不导出回执,而不是截断。
- **不存在不代表任何结果。** 既不意味着成功,也不意味着失败。
- **要么精确,要么不存在。** `command` 和 `cwd` 绝不截断。任一超过 8 KiB、包含 NUL,或目录是相对路径、非 UTF-8 或在启动前无法解析时,不导出回执。shell 工具无法观察到运行如何结束(操作系统的 wait 调用本身失败)时同样不导出:其状态未知,回执不做猜测。预览会缩短直到序列化后的 JSON 不超过 32 KiB;仍然放不下时不导出回执,而不是截断。
- **不存在不代表任何结果。** 既不意味着成功,也不意味着失败。应用当前调用的上下文前,会清除继承的 `DEEPSEEK_TOOL_EXECUTION_RECEIPT`。
- **范围。** 只有配置了 `tool_call_after` 或 `on_error` hook 时才会生成回执,且仅限已结束、基于管道、未沙箱化的本地前台运行。后台启动、转入 `/jobs` 的前台运行、PTY(`tty` / `combined_output`)与交互会话、OS 沙箱与外部后端执行、只读 shell 的加固 argv、Windows、任何平台上的 PowerShell(它会包装源码或通过临时脚本运行),以及执行前就被拒绝的调用都没有回执。
- **仅供 hook 使用。** 回执不写入持久化的 Runtime API 条目记录;该记录已包含工具输出。
- 失败的运行与成功的运行同样设置,因此 shell 调用失败时的 `on_error` 也带有它。其他变量均不变。