Files
codex/codex-rs/shell-command/src/shell_snapshot_render.rs
Winston Howes ec512d2347 Harden credential handling in shell snapshots and replay (#44040)
## Why

Shell quoting can hide credentials from raw-text checks, and startup files can restore real credentials after the broker replaces them with dummy values.

## What changed

- Decode shell literals without evaluating them and reject snapshots containing credentials in executable source, including aliases, functions, and heredocs.
- Preserve credential policy overrides, explicit unsets, and aliases whose source variables were removed. Support credential aliases in Zsh tied arrays while rejecting credentials that span array elements.
- Guard snapshot replay against credential restoration through shell startup files and preserve unrelated `ENV` settings.
- Apply Windows environment-key casing rules to credential overrides, suppress unredacted sandbox diagnostics during snapshot capture, and clear inherited environment variables before launching escalated commands.

## Testing

Add regression coverage for shell quoting and escaped credentials, Zsh tied arrays, startup-file replay, readonly credentials, policy overrides, and sensitive capture timeout and cancellation handling.

GitOrigin-RevId: 58274c07c715423241d26ce6dd2c2b4230cf0f64
2026-09-09 04:00:10 +00:00

107 lines
3.0 KiB
Rust

//! Serialize prepared snapshot values without inspecting credentials or executing shell code.
//! Native declarations stay unchanged unless the credential stage supplies a replacement.
use super::capture::CapturedSnapshot;
impl CapturedSnapshot<'_> {
/// Render native functions, options, and aliases; the executor restores environment separately.
pub fn render_state(&self) -> String {
format!("{}{}", self.state, self.aliases)
}
/// Render a complete replay script without applying credential or environment policy.
pub fn render_script(&self) -> String {
let mut script = self.render_state();
script.push_str("# exports (native declarations)\n");
for export in &self.exports {
script.push_str(&export.source);
}
script
}
}
#[derive(Default)]
pub(super) struct Value {
pub parts: Vec<ValuePart>,
}
pub(super) enum ValuePart {
Literal(String),
Credential { key: String },
}
pub(super) enum Export<'a> {
Captured(&'a str),
Assignment {
declaration: &'a str,
value: Value,
},
Array {
prefix: &'a str,
elements: Vec<Value>,
suffix: &'a str,
},
ArrayBinding {
key: &'a str,
declaration: &'a str,
suffix: &'a str,
},
}
impl Value {
fn render(&self) -> Option<String> {
let mut output = String::new();
for part in &self.parts {
match part {
ValuePart::Literal(value) => {
output.push_str(shlex::try_quote(value).ok()?.as_ref())
}
ValuePart::Credential { key } => {
output.push_str(&format!("\"${{{key}-}}\""));
}
}
}
if output.is_empty() {
output.push_str("''");
}
Some(output)
}
}
pub(super) fn render(state: &str, aliases: &str, exports: &[Export<'_>]) -> Option<String> {
let mut output = format!("{state}{aliases}");
if !exports.is_empty() {
output.push_str("# exports (native declarations)\n");
}
for export in exports {
match export {
Export::Captured(source) => output.push_str(source),
Export::Assignment { declaration, value } => {
output.push_str(&format!("{declaration}={}\n", value.render()?));
}
Export::Array {
prefix,
elements,
suffix,
} => {
let elements = elements
.iter()
.map(Value::render)
.collect::<Option<Vec<_>>>()?;
output.push_str(&format!("{prefix}({}){suffix}", elements.join(" ")));
}
Export::ArrayBinding {
key,
declaration,
suffix,
} => {
output.push_str(&format!(
"if [ \"${{{key}+x}}\" = x ]; then\n{declaration}{}\nfi\n",
suffix.trim_end()
));
}
}
}
Some(output)
}