Protect shell snapshots when credential brokerage is enabled (#43909)

## Why

Shell startup files can introduce credentials or overwrite brokered dummy values. Snapshot capture and replay need to respect the command's sandbox and environment policy without persisting real credentials.

## What changed

- Capture and validate brokered snapshots per command using its requested shell, working directory, login mode, sandbox, and environment policy.
- Discover shell-initialized credentials and prepare snapshot exports for brokered replay. Preserve shell functions and aliases while preventing startup files from overwriting dummy credentials during replay.
- Rebuild snapshots as credential broker state changes and retain each command's snapshot for the lifetime of its process.
- Reject brokered POSIX shell execution when a protected snapshot cannot be created, and reject brokerage with `shell_zsh_fork`. Restore real credentials and remove brokerage markers for unsupported PowerShell and Cmd execution; escalated commands bypass protected snapshots.

## Testing

Add regression coverage for credential discovery and redaction, environment filtering, readonly exports, shell function and heredoc replay, command working directories and login modes, sandbox enforcement, and missing or disabled snapshots.

GitOrigin-RevId: 685356cb9e80f11cf69d053e4d239ee6ccf43a9e
This commit is contained in:
Winston Howes
2026-09-08 20:59:50 +00:00
committed by copyberry
parent 1530f828cb
commit 2e220af1f6
25 changed files with 2080 additions and 133 deletions
@@ -34,6 +34,7 @@ use super::UNIFIED_EXEC_OUTPUT_MAX_TOKENS;
use super::UnifiedExecError;
use super::head_tail_buffer::HeadTailBuffer;
use super::process_state::ProcessState;
use crate::shell_snapshot::ShellSnapshotFile;
const EARLY_EXIT_GRACE_PERIOD: Duration = Duration::from_millis(150);
pub(crate) trait SpawnLifecycle: std::fmt::Debug + Send + Sync {
@@ -99,6 +100,8 @@ pub(crate) struct UnifiedExecProcess {
sandbox_type: SandboxType,
timed_out: AtomicBool,
_spawn_lifecycle: Option<SpawnLifecycleHandle>,
// The shell may still need to replay this file after process startup returns.
pub(crate) _shell_snapshot: Option<Arc<ShellSnapshotFile>>,
}
impl std::fmt::Debug for UnifiedExecProcess {
@@ -140,6 +143,7 @@ impl UnifiedExecProcess {
sandbox_type,
timed_out: AtomicBool::new(false),
_spawn_lifecycle: spawn_lifecycle,
_shell_snapshot: None,
}
}
@@ -2,4 +2,4 @@
source: core/src/unified_exec/stdin_approval_tests.rs
expression: reason
---
Send input to an existing terminal. This terminal retains additional permissions. It also has an internal plugin metrics write grant. The cwd is its launch directory; the terminal's current directory and state may have changed.
Send input to an existing terminal. This terminal retains additional permissions. It also has an internal filesystem grant. The cwd is its launch directory; the terminal's current directory and state may have changed.
@@ -160,7 +160,7 @@ impl TerminalPermissions {
};
let mut reason = format!("Send input to an existing terminal. {authority}");
if self.internal_permissions.is_some() {
reason.push_str(" It also has an internal plugin metrics write grant.");
reason.push_str(" It also has an internal filesystem grant.");
}
reason.push_str(" The cwd is its launch directory; the terminal's current directory and state may have changed.");
if let Some(grants) = &self.additional_permissions {
@@ -163,7 +163,10 @@ async fn internal_grants_require_review_without_exposing_paths() -> anyhow::Resu
environment.config_mut().permission_profile =
PermissionProfileSnapshot::legacy(PermissionProfile::read_only());
let grants = serde_json::from_value(json!({
"file_system": {"write": [turn.config.cwd.join("private-metrics")]}
"file_system": {
"read": [turn.config.cwd.join("private-snapshot")],
"write": [turn.config.cwd.join("private-metrics")]
}
}))?;
let permissions = TerminalPermissions::for_launch(
&environment,
@@ -189,6 +192,34 @@ async fn internal_grants_require_review_without_exposing_paths() -> anyhow::Resu
Ok(())
}
#[tokio::test]
async fn readable_snapshot_does_not_require_stdin_approval() -> anyhow::Result<()> {
let (_session, turn) = make_session_and_context().await;
let mut environment = turn.environments.primary().expect("environment").clone();
environment.config_mut().permission_profile =
PermissionProfileSnapshot::legacy(PermissionProfile::read_only());
let snapshot = turn.config.cwd.join("shell-snapshot.sh");
let grant = crate::shell_snapshot::snapshot_read_permissions(
&snapshot,
&environment.permission_profile_with_workspace_roots(),
environment.cwd(),
);
assert_eq!(grant, None);
let permissions = TerminalPermissions::for_launch(
&environment,
&turn,
TerminalSandboxSource::Native,
SandboxPermissions::UseDefault,
/*additional_permissions*/ None,
grant.as_ref(),
);
assert_eq!(
permissions.review_requirement(&permissions.policy, environment.permission_profile()),
Ok(SandboxPermissions::UseDefault)
);
Ok(())
}
#[test_case::test_case(TerminalSandboxSource::Native, SandboxPermissions::RequireEscalated; "native_disabled_sandbox_needs_review_when_enabled")]
#[test_case::test_case(TerminalSandboxSource::Executor, SandboxPermissions::UseDefault; "executor_keeps_its_restricted_token_default")]
#[tokio::test]