Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10
commit-message:
prefix: "ci"
groups:
actions:
patterns:
- "*"
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
commit-message:
prefix: "chore"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
6 changes: 5 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
- "v*"

permissions:
contents: write
contents: read

env:
CARGO_TERM_COLOR: always
Expand All @@ -16,6 +16,8 @@ jobs:
build:
name: Build x86_64-linux-gnu
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.meta.outputs.version }}
tarball: ${{ steps.package.outputs.tarball }}
Expand Down Expand Up @@ -147,6 +149,8 @@ jobs:
name: Publish GitHub Release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
build.sh
test-project/
phantom-plan-*.md
.llm-fence/
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ tree-sitter-make = "1.1"
diffy = "0.4"
toml_edit = "0.22"
serde_yaml_ng = "0.10"
nix = { version = "0.30", features = ["term", "signal"] }
nix = { version = "0.30", features = ["term", "signal", "fs", "poll"] }
regex = "1"
dialoguer = "0.11"
console = "0.15"
Expand Down Expand Up @@ -100,3 +100,4 @@ lto = "fat"
codegen-units = 1
strip = true
panic = "abort"
overflow-checks = true
2 changes: 2 additions & 0 deletions crates/phantom-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub enum Commands {
/// Roll back a changeset and replay downstream
#[command(alias = "rb", display_name = "rollback/rb")]
Rollback(commands::rollback::RollbackArgs),
/// Reconcile orphan pre-commit fences after a crashed submit
Recover(commands::recover::RecoverArgs),
/// Query the event log
#[command(alias = "l", display_name = "log/l")]
Log(commands::log::LogArgs),
Expand Down
64 changes: 52 additions & 12 deletions crates/phantom-cli/src/commands/agent_monitor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,25 @@ pub async fn run(args: AgentMonitorArgs) -> anyhow::Result<()> {

let ctx = PhantomContext::locate()?;
let events = ctx.open_events().await?;
let agent_id = AgentId(args.agent.clone());
// Re-validate the agent name: `_agent-monitor` is reachable as a hidden
// subcommand and its `--agent` flag would otherwise flow unchecked into
// path joins and the embedded hook command string.
let agent_id = AgentId::validate(&args.agent)
.map_err(|e| anyhow::anyhow!("invalid agent name '{}': {}", args.agent, e))?;
let changeset_id = ChangesetId(args.changeset_id.clone());
let work_dir = PathBuf::from(&args.work_dir);

// Wait for upstream dependencies to materialize before starting.
// Each upstream name is validated the same way as the primary agent.
let upstream_agents: Vec<AgentId> = args
.depends_on_agents
.split(',')
.filter(|s| !s.is_empty())
.map(|s| AgentId(s.to_string()))
.collect();
.map(|s| {
AgentId::validate(s)
.map_err(|e| anyhow::anyhow!("invalid upstream agent name '{s}': {e}"))
})
.collect::<anyhow::Result<Vec<_>>>()?;

if !upstream_agents.is_empty() {
deps::wait_for_dependencies(
Expand Down Expand Up @@ -161,10 +169,13 @@ pub async fn run(args: AgentMonitorArgs) -> anyhow::Result<()> {
)?;

// Emit AgentLaunched event now that we have the real PID.
// Propagate query errors rather than flattening them to `None`: a
// silently root-linked event corrupts the causal chain used by
// rollback ordering.
let causal_parent = events
.latest_event_for_changeset(&changeset_id)
.await
.unwrap_or(None);
.map_err(|e| anyhow::anyhow!("failed to resolve causal parent for AgentLaunched: {e}"))?;
let launch_event = Event {
id: EventId(0),
timestamp: Utc::now(),
Expand Down Expand Up @@ -223,15 +234,42 @@ pub async fn run(args: AgentMonitorArgs) -> anyhow::Result<()> {
let (status, should_remove) =
reconcile_with_event_log(&events, &agent_id, &changeset_id, status, should_remove).await;

// Write status file while the overlay still exists.
// Write status file while the overlay still exists. Both the serialize
// and the write are observable failures: the status file is the only
// persistent record of completion read by `ph status` and `ph background`.
// Silently losing it makes the reconcile path invent "died unexpectedly"
// markers after the next CLI invocation.
let status_file = status_path(&ctx.phantom_dir, &args.agent);
if let Ok(json) = serde_json::to_string_pretty(&status) {
let _ = std::fs::write(&status_file, json);
match serde_json::to_string_pretty(&status) {
Ok(json) => {
if let Err(err) = std::fs::write(&status_file, &json) {
tracing::error!(
path = %status_file.display(),
%err,
"failed to write agent status file",
);
}
}
Err(err) => {
tracing::error!(%err, "failed to serialize agent status");
}
}

// Clean up PID files.
let _ = std::fs::remove_file(pid_path(&ctx.phantom_dir, &args.agent));
let _ = std::fs::remove_file(monitor_pid_path(&ctx.phantom_dir, &args.agent));
// Clean up PID files. A failure here leaves stale markers that
// `cleanup_stale_agent_process` would later interpret as a crashed
// process and override the real status — log so we can correlate.
let agent_pid = pid_path(&ctx.phantom_dir, &args.agent);
if let Err(err) = std::fs::remove_file(&agent_pid)
&& err.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(path = %agent_pid.display(), %err, "failed to remove agent PID file");
}
let monitor_pid = monitor_pid_path(&ctx.phantom_dir, &args.agent);
if let Err(err) = std::fs::remove_file(&monitor_pid)
&& err.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(path = %monitor_pid.display(), %err, "failed to remove monitor PID file");
}

// Auto-remove overlay after successful submit. On conflict or failure
// the overlay is preserved for `phantom resolve` or manual recovery.
Expand Down Expand Up @@ -388,11 +426,13 @@ pub(super) async fn run_post_completion(

let success = exit_code == Some(0);

// Record completion event.
// Record completion event. AgentCompleted is the event rollback must
// reach to determine how far to replay, so we cannot afford to silently
// root-link it on a transient query failure.
let causal_parent = events
.latest_event_for_changeset(changeset_id)
.await
.unwrap_or(None);
.map_err(|e| anyhow::anyhow!("failed to resolve causal parent for AgentCompleted: {e}"))?;
let event = Event {
id: EventId(0),
timestamp: Utc::now(),
Expand Down
18 changes: 15 additions & 3 deletions crates/phantom-cli/src/commands/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,9 +447,21 @@ fn collect_background_agents(phantom_dir: &Path, agents: &mut Vec<AgentId>) {
|| dir.join("waiting.json").exists()
|| dir.join("monitor.pid").exists();
if is_background {
let id = AgentId(name.to_string());
if !agents.contains(&id) {
agents.push(id);
// Directory names come from disk — validate before use as
// an AgentId so a crafted dir name cannot traverse paths.
match AgentId::validate(name) {
Ok(id) => {
if !agents.contains(&id) {
agents.push(id);
}
}
Err(err) => {
tracing::warn!(
dir = %dir.display(),
%err,
"skipping overlay dir with invalid agent name",
);
}
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion crates/phantom-cli/src/commands/changes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ pub async fn run(args: ChangesArgs) -> anyhow::Result<()> {
let events_store = ctx.open_events().await?;

let query = if let Some(ref agent) = args.agent {
let agent_id = AgentId::validate(agent)
.map_err(|e| anyhow::anyhow!("invalid agent name '{agent}': {e}"))?;
EventQuery {
agent_id: Some(AgentId(agent.clone())),
agent_id: Some(agent_id),
limit: Some(args.limit),
kind_prefixes: vec![
"ChangesetMaterialized".to_string(),
Expand Down
13 changes: 11 additions & 2 deletions crates/phantom-cli/src/commands/down.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ pub fn run(args: &DownArgs) -> anyhow::Result<()> {
Ok(())
}

/// List agent directory names under `.phantom/overlays/`.
/// List agent directory names under `.phantom/overlays/`, rejecting
/// entries whose names are not valid agent ids (they would otherwise flow
/// into path joins during teardown and SIGTERM delivery).
fn list_agent_dirs(overlays_dir: &Path) -> Vec<String> {
let mut agents = Vec::new();
let Ok(entries) = std::fs::read_dir(overlays_dir) else {
Expand All @@ -146,7 +148,14 @@ fn list_agent_dirs(overlays_dir: &Path) -> Vec<String> {
if entry.file_type().is_ok_and(|ft| ft.is_dir())
&& let Some(name) = entry.file_name().to_str()
{
agents.push(name.to_string());
if phantom_core::AgentId::validate(name).is_ok() {
agents.push(name.to_string());
} else {
tracing::warn!(
dir = %entry.path().display(),
"skipping overlay dir with invalid agent name during teardown",
);
}
}
}
agents.sort();
Expand Down
7 changes: 6 additions & 1 deletion crates/phantom-cli/src/commands/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,13 @@ impl Drop for FuseCleanupGuard {
pub fn run(args: &ExecArgs) -> anyhow::Result<()> {
let ctx = PhantomContext::locate()?;

// Validate the agent name before using it as a path component; an
// unvalidated value would allow traversal out of `.phantom/overlays/`.
let agent_id = phantom_core::AgentId::validate(&args.agent)
.map_err(|e| anyhow::anyhow!("invalid agent name '{}': {}", args.agent, e))?;

// Validate agent exists.
let overlay_root = ctx.phantom_dir.join("overlays").join(&args.agent);
let overlay_root = ctx.phantom_dir.join("overlays").join(agent_id.0.as_str());
let upper_dir = overlay_root.join("upper");
if !upper_dir.exists() {
anyhow::bail!(
Expand Down
8 changes: 7 additions & 1 deletion crates/phantom-cli/src/commands/fuse_mount.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,13 @@ pub fn run(args: FuseMountArgs) -> anyhow::Result<()> {
let layer = OverlayLayer::new(args.lower_dir.clone(), args.upper_dir.clone())
.context("failed to create overlay layer")?;

let agent_id = AgentId(args.agent.clone());
// Re-validate the agent name even though the caller is typically a
// trusted sibling process: `_fuse-mount` is reachable as a CLI
// subcommand and constructing `AgentId` from a raw CLI flag without
// validation would allow path-traversal-like values (`../etc`, null
// bytes) to flow into overlay paths and log output.
let agent_id = AgentId::validate(&args.agent)
.map_err(|e| anyhow::anyhow!("invalid agent name '{}': {}", args.agent, e))?;

let mut fs_config = FsConfig::default();
if let Some(uid) = args.uid {
Expand Down
8 changes: 8 additions & 0 deletions crates/phantom-cli/src/commands/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ pub async fn run_interactive_session(
) -> anyhow::Result<()> {
let config_default = crate::context::default_cli(&ctx.phantom_dir);
let command = args.command.as_deref().unwrap_or(&config_default);
// Validate the CLI name against the same allowlist that the background
// path uses. The command string flows into `Command::new(...)` inside
// the GenericAdapter and was previously only validated when `--background`
// was passed, leaving the interactive path open to arbitrary binaries.
crate::context::validate_cli_name(command)
.map_err(|e| anyhow::anyhow!("invalid --command value '{command}': {e}"))?;
let cli_adapter = adapter::adapter_for(command);

// Detect repo toolchain once and thread it through so the context file
Expand Down Expand Up @@ -92,6 +98,8 @@ pub async fn run_interactive_session(

// Use PTY when stdin is a terminal (enables output capture for session IDs).
// Fall back to direct Stdio::inherit() when not a TTY (tests, CI, piped input).
// SAFETY: STDIN_FILENO (0) is always a valid fd; isatty on a closed fd returns
// 0 (errno=EBADF), never UB.
let is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) == 1 };
let (exit_status, captured_session_id) = if is_tty {
pty::spawn_with_pty(
Expand Down
21 changes: 19 additions & 2 deletions crates/phantom-cli/src/commands/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,19 @@ pub async fn run(args: LogArgs) -> anyhow::Result<()> {
let since = args.since.as_deref().map(parse_duration_ago).transpose()?;

// Auto-detect whether the positional filter is an agent or changeset.
// Both values flow into SQL and log output — validate to reject crafted
// names (path traversal, control bytes, etc.).
let (agent_id, changeset_id) = match &args.filter {
Some(f) if f.starts_with("cs-") => (None, Some(ChangesetId(f.clone()))),
Some(f) => (Some(AgentId(f.clone())), None),
Some(f) if f.starts_with("cs-") => {
let cs = ChangesetId::validate(f)
.map_err(|e| anyhow::anyhow!("invalid changeset id '{f}': {e}"))?;
(None, Some(cs))
}
Some(f) => {
let agent = AgentId::validate(f)
.map_err(|e| anyhow::anyhow!("invalid agent name '{f}': {e}"))?;
(Some(agent), None)
}
None => (None, None),
};

Expand Down Expand Up @@ -258,6 +268,12 @@ fn format_event_kind(kind: &phantom_core::EventKind) -> String {
};
format!("merge-checked {{ {status} }}")
}
EventKind::ChangesetMaterializationStarted { parent, path } => {
format!(
"materialization-started {{ parent: {}, path: {path:?} }}",
short_hex(&parent.to_hex())
)
}
EventKind::ChangesetMaterialized { new_commit } => {
format!(
"materialized {{ commit: {} }}",
Expand Down Expand Up @@ -364,6 +380,7 @@ fn event_kind_label(kind: &phantom_core::EventKind) -> &'static str {
EventKind::ChangesetSubmitted { .. } | EventKind::ChangesetMaterialized { .. } => {
"submitted"
}
EventKind::ChangesetMaterializationStarted { .. } => "materializing",
EventKind::ChangesetMergeChecked { .. } => "merge checked",
EventKind::ChangesetConflicted { .. } => "conflicted",
EventKind::ChangesetDropped { .. } => "dropped",
Expand Down
1 change: 1 addition & 0 deletions crates/phantom-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod interactive;
pub mod log;
pub mod notify_hook;
pub mod plan;
pub mod recover;
pub mod remove;
pub mod resolve;
pub mod resume;
Expand Down
Loading
Loading