bascule-oss/crates/bascule-core/src/pty.rs
Tyler King 043b9b9bdc feat: bascule-shell — identity-aware shell with TPM attestation
New crate: bascule-shell (471 lines, 1.8MB binary)
  Login shell that detects identity + platform attestation at startup.
  Wraps bash/zsh/fish — operator works normally, identity travels with them.

Identity detection (priority order):
  1. Entra via WSL2 interop
  2. Azure CLI
  3. Kerberos TGT
  4. Cached OIDC token
  5. System user (fallback)

Platform attestation:
  TPM 2.0 PCR values via tpm2_pcrread (PCRs 0,1,2,7,10,14)
  IMA measurement log hash + count
  Keylime agent state
  Entra device compliance (WSL2 only)
  Composite SHA-256 hash over all evidence

Shell features:
  Banner with identity + attestation summary
  BASCULE_* env vars injected into inner shell
  --info mode for dry-run display
  --json mode for machine-readable output
  --exec mode for single-command execution
  Configurable via ~/.config/bascule/shell.toml

Tested on Fedora with real TPM 2.0:
  6 PCRs successfully read from hardware
  All env vars propagated to inner shell
  1.8MB binary, 0 substrate deps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:47:46 -04:00

84 lines
2.1 KiB
Rust

//! PTY bridge — spawns a shell and bridges I/O to the SSH channel.
use anyhow::{Context, Result};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use std::io::{Read, Write};
/// Spawn a PTY with the given command and return the bridge.
pub fn spawn_pty(
command: &str,
args: &[String],
env: Vec<(String, String)>,
cols: u16,
rows: u16,
) -> Result<PtyBridge> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("Failed to open PTY")?;
let mut cmd = CommandBuilder::new(command);
for arg in args {
cmd.arg(arg);
}
for (key, val) in &env {
cmd.env(key, val);
}
let child = pair
.slave
.spawn_command(cmd)
.context("Failed to spawn shell")?;
let reader = pair
.master
.try_clone_reader()
.context("Failed to clone PTY reader")?;
let writer = pair
.master
.take_writer()
.context("Failed to take PTY writer")?;
Ok(PtyBridge {
master: pair.master,
child,
reader: Some(reader),
writer,
})
}
/// A running PTY session with I/O handles.
pub struct PtyBridge {
pub master: Box<dyn portable_pty::MasterPty + Send>,
pub child: Box<dyn portable_pty::Child + Send + Sync>,
/// PTY reader — `take()` this to move it into a dedicated read thread.
pub reader: Option<Box<dyn Read + Send>>,
pub writer: Box<dyn Write + Send>,
}
impl PtyBridge {
/// Take the reader out of this bridge for use on a dedicated thread.
/// Returns None if already taken.
pub fn take_reader(&mut self) -> Option<Box<dyn Read + Send>> {
self.reader.take()
}
/// Resize the PTY.
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
self.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("Failed to resize PTY")?;
Ok(())
}
}