fix(shell): detect Docker buildx provenance EPERM and surface actionable hint

When exec_shell runs a Docker build on macOS and Docker Desktop's signed
process has written com.apple.provenance-tagged files under
~/.docker/buildx/activity/, the child process spawned by the TUI
sandbox gets EPERM when it tries to update those files, producing:

  failed to update builder last activity time: open
  /Users/.../.docker/buildx/activity/.tmp-...: operation not permitted

Add looks_like_macos_provenance_failure() to detect this pattern via
three heuristics (provenance xattr mention, activity-time message, or
buildx/activity path + EPERM), with an early-return guard that suppresses
the hint on clean exits. Wire the hint into both the foreground exec_shell
path and build_shell_delta_tool_result so it surfaces on background task
polls too.

Four unit tests cover the positive cases and the two guard cases (exit 0,
unrelated EPERM).

Closes #1449

Signed-off-by: CrepuscularIRIS <serenitygp@qq.com>
This commit is contained in:
CrepuscularIRIS
2026-05-11 13:07:03 -04:00
committed by Hunter Bown
parent 97a77d82f0
commit ae45d1054b
2 changed files with 96 additions and 0 deletions
+42
View File
@@ -1418,6 +1418,31 @@ const FOREGROUND_TIMEOUT_RECOVERY_HINT: &str = "Foreground exec_shell is for bou
The timed-out process was killed; rerun long work with task_shell_start or exec_shell with \
background: true, then poll with task_shell_wait or exec_shell_wait.";
const MACOS_PROVENANCE_HINT: &str = "Docker buildx failed to update its activity file due to a macOS \
com.apple.provenance restriction. Files created by Docker Desktop's signed process carry a \
kernel-enforced provenance tag that blocks writes from child processes (including the TUI \
shell sandbox). Workarounds: (1) run the Docker build from a regular terminal outside the \
TUI, or (2) disable BuildKit with DOCKER_BUILDKIT=0 (only works if your Dockerfiles do not \
use RUN --mount directives).";
pub(crate) fn looks_like_macos_provenance_failure(result: &ShellResult) -> bool {
if matches!(result.status, ShellStatus::Completed) && result.exit_code == Some(0) {
return false;
}
let combined = format!("{}\n{}", result.stdout, result.stderr).to_ascii_lowercase();
combined.contains("com.apple.provenance")
|| combined.contains("update builder last activity")
|| (combined.contains("buildx/activity") && combined.contains("operation not permitted"))
}
fn macos_provenance_hint(result: &ShellResult) -> Option<&'static str> {
if looks_like_macos_provenance_failure(result) {
Some(MACOS_PROVENANCE_HINT)
} else {
None
}
}
fn command_likely_needs_network(command: &str) -> bool {
let normalized = command.to_ascii_lowercase();
let Some(primary) = extract_primary_command(&normalized) else {
@@ -1933,6 +1958,7 @@ impl ToolSpec for ExecShellTool {
};
let network_restricted_hint =
shell_network_restricted_hint(context, command, &result).map(str::to_string);
let provenance_hint = macos_provenance_hint(&result);
let mut output = if interactive {
format!(
"Interactive command completed (exit code: {:?})",
@@ -1973,6 +1999,9 @@ impl ToolSpec for ExecShellTool {
if let Some(hint) = network_restricted_hint.as_deref() {
output = format!("{hint}\n\n{output}");
}
if let Some(hint) = provenance_hint {
output = format!("{hint}\n\n{output}");
}
let mut metadata = json!({
"exit_code": result.exit_code,
@@ -2027,6 +2056,9 @@ impl ToolSpec for ExecShellTool {
metadata["sandbox_network_restricted"] = json!(true);
metadata["sandbox_network_denied_hint"] = json!(hint);
}
if provenance_hint.is_some() {
metadata["macos_provenance_restricted"] = json!(true);
}
Ok(ToolResult {
content: output,
@@ -2072,6 +2104,7 @@ fn build_shell_delta_tool_result(delta: ShellDeltaResult, context: &ToolContext)
let result = delta.result;
let network_restricted_hint =
shell_network_restricted_hint(context, &delta.command, &result).map(str::to_string);
let provenance_hint = macos_provenance_hint(&result);
let stdout_summary = summarize_output(&result.stdout);
let stderr_summary = summarize_output(&result.stderr);
let summary = if !stderr_summary.is_empty() {
@@ -2096,6 +2129,9 @@ fn build_shell_delta_tool_result(delta: ShellDeltaResult, context: &ToolContext)
if let Some(hint) = network_restricted_hint.as_deref() {
output = format!("{hint}\n\n{output}");
}
if let Some(hint) = provenance_hint {
output = format!("{hint}\n\n{output}");
}
let mut tool_result = ToolResult {
content: output,
@@ -2129,6 +2165,12 @@ fn build_shell_delta_tool_result(delta: ShellDeltaResult, context: &ToolContext)
object.insert("sandbox_network_restricted".to_string(), json!(true));
object.insert("sandbox_network_denied_hint".to_string(), json!(hint));
}
if provenance_hint.is_some()
&& let Some(metadata) = tool_result.metadata.as_mut()
&& let Some(object) = metadata.as_object_mut()
{
object.insert("macos_provenance_restricted".to_string(), json!(true));
}
tool_result
}
+54
View File
@@ -689,3 +689,57 @@ async fn test_exec_shell_cancel_tool_can_kill_all_running_processes() {
assert_eq!(first_job.snapshot.status, ShellStatus::Killed);
assert_eq!(second_job.snapshot.status, ShellStatus::Killed);
}
fn make_failed_result(stderr: &str) -> ShellResult {
ShellResult {
task_id: None,
status: ShellStatus::Failed,
exit_code: Some(1),
stdout: String::new(),
stderr: stderr.to_string(),
duration_ms: 0,
stdout_len: 0,
stderr_len: stderr.len(),
stdout_omitted: 0,
stderr_omitted: 0,
stdout_truncated: false,
sandboxed: false,
sandbox_type: None,
sandbox_denied: false,
stderr_truncated: false,
}
}
#[test]
fn test_macos_provenance_detected_by_activity_time_message() {
let result = make_failed_result(
"failed to update builder last activity time: open \
/Users/user/.docker/buildx/activity/.tmp-abc: operation not permitted",
);
assert!(looks_like_macos_provenance_failure(&result));
}
#[test]
fn test_macos_provenance_detected_by_activity_path_and_eperm() {
let result = make_failed_result(
"error: open /home/user/.docker/buildx/activity/foo: operation not permitted",
);
assert!(looks_like_macos_provenance_failure(&result));
}
#[test]
fn test_macos_provenance_not_triggered_on_success() {
let mut result = make_failed_result(
"failed to update builder last activity time: open \
/Users/user/.docker/buildx/activity/.tmp-abc: operation not permitted",
);
result.status = ShellStatus::Completed;
result.exit_code = Some(0);
assert!(!looks_like_macos_provenance_failure(&result));
}
#[test]
fn test_macos_provenance_not_triggered_on_unrelated_eperm() {
let result = make_failed_result("open /some/other/path: operation not permitted");
assert!(!looks_like_macos_provenance_failure(&result));
}