Skip to content
Open
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
1 change: 1 addition & 0 deletions roar/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
# Lazy command registry: maps command name to (module_path, command_name, short_help)
# Short help is stored here to avoid importing commands just for --help
LAZY_COMMANDS: dict[str, tuple[str, str, str]] = {
"agent": ("roar.cli.commands.agent", "agent", "Run an agent with provenance tracking"),
"auth": ("roar.cli.commands.auth", "auth", "Manage authentication with GLaaS"),
"build": ("roar.cli.commands.build", "build", "Run a build step before the main pipeline"),
"config": ("roar.cli.commands.config", "config", "View or set configuration"),
Expand Down
21 changes: 20 additions & 1 deletion roar/cli/commands/_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,25 @@ def validate_git_clean() -> str:
return repo_root


def get_git_root_optional() -> str:
"""Get git repo root if available, otherwise return cwd.

Unlike ``validate_git_clean``, this does **not** require a git repo and
does **not** check for uncommitted changes. Suitable for ``roar agent``
where the agent is expected to modify the working tree.
"""
import subprocess

try:
return subprocess.check_output(
["git", "rev-parse", "--show-toplevel"],
stderr=subprocess.DEVNULL,
text=True,
).strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return os.getcwd()


def get_quiet_setting(quiet_flag: bool | None, repo_root: str | Path) -> bool:
"""
Get quiet setting from CLI flag or config.
Expand Down Expand Up @@ -186,7 +205,7 @@ def execute_and_report(

# Create run context
hash_algos = cast(list[Literal["blake3", "sha256", "sha512", "md5"]], hash_algorithms)
job_type_literal = cast(Literal["run", "build"] | None, job_type)
job_type_literal = cast(Literal["run", "build", "agent"] | None, job_type)
run_ctx = RunContext(
roar_dir=ctx.roar_dir,
repo_root=repo_root,
Expand Down
118 changes: 118 additions & 0 deletions roar/cli/commands/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""
Native Click implementation of the agent command.

Usage: roar agent [options] <command>
"""

import click

from ...core.tracer_modes import TRACER_MODE_VALUES
from ..context import RoarContext
from ..decorators import require_init
from ._execution import (
execute_and_report,
get_git_root_optional,
get_hash_algorithms,
get_quiet_setting,
)


@click.command(
"agent",
context_settings={
"ignore_unknown_options": True,
"allow_extra_args": True,
"allow_interspersed_args": False,
},
)
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
@click.option("-q", "--quiet", is_flag=True, default=None, help="Suppress output summary")
@click.option("-n", "--name", "step_name", help="Name for this step")
@click.option(
"--tracer",
"tracer_mode",
type=click.Choice(list(TRACER_MODE_VALUES)),
default=None,
help="Tracer backend policy for this run",
)
@click.option(
"--tracer-fallback/--no-tracer-fallback",
"tracer_fallback",
default=None,
help="Allow runtime fallback to another tracer backend",
)
@click.option("--hash", "hash_algorithms", multiple=True, help="Add hash algorithm")
@click.pass_obj
@require_init
def agent(
ctx: RoarContext,
args: tuple[str, ...],
quiet: bool | None,
step_name: str | None,
tracer_mode: str | None,
tracer_fallback: bool | None,
hash_algorithms: tuple[str, ...],
) -> None:
"""Run an agent with provenance tracking.

Like 'roar run' but does not require a clean git working tree.
Tracks all file I/O performed by the agent and its subprocesses.

\\b
Examples:
roar agent codex
roar agent bash ./my-agent-script.sh
roar agent python my_agent.py
"""
args_list = list(args)

if not args_list or args_list[0] in ("-h", "--help"):
click.echo(_get_help_text())
return

repo_root = get_git_root_optional()
quiet_setting = get_quiet_setting(quiet, repo_root)
algorithms = get_hash_algorithms(list(hash_algorithms) if hash_algorithms else None)

command = args_list
if not command:
click.echo(_get_help_text())
raise click.ClickException("No command specified")

exit_code = execute_and_report(
ctx=ctx,
command=command,
job_type="agent",
step_name=step_name,
quiet=quiet_setting,
hash_algorithms=algorithms,
repo_root=repo_root,
tracer_mode=tracer_mode,
tracer_fallback=tracer_fallback,
)

if exit_code != 0:
raise SystemExit(exit_code)


def _get_help_text() -> str:
"""Get help text for the agent command."""
return """Usage: roar agent [options] <command> [args...]

Run an agent with provenance tracking.

Unlike 'roar run', this does not require a clean git working tree.
The agent and all its subprocesses are traced for file I/O.

Options:
--quiet, -q Suppress output summary
--tracer <mode> Tracer policy: auto, ebpf, preload, ptrace
--tracer-fallback Enable runtime tracer fallback
--no-tracer-fallback Disable runtime tracer fallback
--hash <algo> Add hash algorithm (can be repeated)
-n, --name <name> Name for this step

Examples:
roar agent codex
roar agent bash ./my-agent-script.sh
roar agent python my_agent.py"""
2 changes: 1 addition & 1 deletion roar/core/models/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

# Type aliases
HashAlgorithm = Literal["blake3", "sha256", "sha512", "md5"]
JobType = Literal["run", "build"]
JobType = Literal["run", "build", "agent"]


class RunArguments(ImmutableModel):
Expand Down
4 changes: 3 additions & 1 deletion roar/plugins/vcs/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ def get_info(self, repo_root: str) -> VCSInfo:
def get_status(self, repo_root: str) -> tuple[bool, list[str]]:
"""Get the git working tree status."""
try:
out = subprocess.check_output(["git", "status", "--porcelain=v1"], cwd=repo_root)
out = subprocess.check_output(
["git", "status", "--porcelain=v1"], cwd=repo_root, stderr=subprocess.DEVNULL
)
lines = out.decode().splitlines()
clean = len(lines) == 0
return clean, lines
Expand Down
42 changes: 41 additions & 1 deletion roar/presenters/show_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,20 @@ def render_job(

lines.append(f"\nCommand: {job['command']}")

# Agent process tree
meta = job.get("metadata")
if job.get("job_type") == "agent" and meta and isinstance(meta, dict):
process_tree = meta.get("process_tree")
if process_tree:
self._render_agent_process_tree(lines, process_tree)

# Git info
if job.get("git_commit"):
lines.append(f"\nGit commit: {job['git_commit']}")
if job.get("git_branch"):
lines.append(f"Git branch: {job['git_branch']}")

# Metadata (what gets registered with GLaaS)
meta = job.get("metadata")
if meta and isinstance(meta, dict):
lines.append("\nMetadata:")

Expand Down Expand Up @@ -259,6 +265,40 @@ def render_job(

return "\n".join(lines)

@staticmethod
def _format_process_cmd(cmd: list[str] | None, max_len: int = 120) -> str:
"""Format a command list as a single-line display string."""
import shlex

if not cmd:
return "(unknown)"
cmd_str = shlex.join(cmd).replace("\n", " ").replace(" ", " ")
if len(cmd_str) > max_len:
cmd_str = cmd_str[: max_len - 3] + "..."
return cmd_str

@classmethod
def _render_agent_process_tree(cls, lines: list[str], tree: list[dict]) -> None:
"""Render executed commands for an agent job."""
if not tree:
return

lines.append("\nExecuted commands:")
for node in tree:
cls._render_process_node(lines, node, depth=0)

@classmethod
def _render_process_node(
cls, lines: list[str], node: dict, depth: int
) -> None:
"""Recursively render a process tree node with indentation."""
cmd_str = cls._format_process_cmd(node.get("command"))
indent = " " + " " * depth
connector = "" if depth == 0 else "- "
lines.append(f"{indent}{connector}{cmd_str}")
for child in node.get("children", []):
cls._render_process_node(lines, child, depth + 1)

def render_artifact(
self,
artifact: dict,
Expand Down
7 changes: 7 additions & 0 deletions roar/services/execution/job_recording.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,13 @@ def _build_metadata_json(
except Exception:
pass

# For agent jobs, include the process tree so `roar show` can render
# the top-level commands the agent spawned.
if getattr(ctx, "job_type", None) == "agent":
process_tree = prov.get("processes")
if process_tree:
metadata["process_tree"] = process_tree

return json.dumps(metadata) if metadata else None

def _build_telemetry_json(self, repo_root: str, start_time: float) -> str | None:
Expand Down
20 changes: 16 additions & 4 deletions roar/services/execution/provenance/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,15 +284,27 @@ def _build_process_info(self, processes: list) -> list:
return process_info

def _get_git_info(self, repo_root: str) -> dict[str, Any]:
"""Get git info via VCS provider."""
"""Get git info via VCS provider.

Returns an empty dict if the repo_root is not inside a git repository
(e.g. when invoked via ``roar agent`` outside a repo).
"""
try:
vcs = get_container().get_vcs_provider("git")
from ....integrations import get_vcs_provider

vcs = get_vcs_provider("git")
vcs_info = vcs.get_info(repo_root)
except KeyError:
# Defensive fallback if plugin bootstrap/registration was skipped.
from ....plugins.vcs.git import GitVCSProvider

vcs_info = GitVCSProvider().get_info(repo_root)
try:
vcs_info = GitVCSProvider().get_info(repo_root)
except Exception:
self.logger.debug("Git info unavailable for %s", repo_root)
return {}
except Exception:
self.logger.debug("Git info unavailable for %s", repo_root)
return {}

return {
"commit": vcs_info.commit,
Expand Down
8 changes: 8 additions & 0 deletions rust/crates/tracer-schema/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ pub enum NativeTraceEvent {
thread_id: u32,
path: String,
},
Fork {
parent_pid: u32,
child_pid: u32,
},
Exec {
pid: u32,
command: Vec<String>,
},
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
31 changes: 31 additions & 0 deletions rust/tracers/preload/src/interpose.c
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,35 @@ int creat(const char *path, mode_t mode) {
}
return ret;
}

/* ── fork/vfork interposition ─────────────────────────────── */

extern void roar_preload_emit_fork(unsigned int parent_pid,
unsigned int child_pid);

pid_t fork(void) {
static pid_t (*real_fork)(void) = NULL;
if (real_fork == NULL) {
real_fork = (pid_t (*)(void))dlsym(RTLD_NEXT, "fork");
}
if (real_fork == NULL) {
return -1;
}

pid_t ret = real_fork();
if (ret > 0) {
/* Parent side — successful fork. */
roar_preload_emit_fork((unsigned int)getpid(), (unsigned int)ret);
}
return ret;
}

/* ── exec notification via constructor ────────────────────── */

extern void roar_preload_notify_exec(void);

__attribute__((constructor)) static void roar_preload_ctor(void) {
roar_preload_notify_exec();
}

#endif
39 changes: 39 additions & 0 deletions rust/tracers/preload/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,45 @@ fn resolve_at_path(dirfd: c_int, path: *const c_char) -> Option<String> {
Some(format!("{base}/{path_s}"))
}

// ── fork/exec event emission ──────────────────────────────────────────────────

/// Called from C `fork()` interposition after a successful fork (parent side).
#[cfg_attr(not(target_os = "macos"), no_mangle)]
pub unsafe extern "C" fn roar_preload_emit_fork(parent_pid: u32, child_pid: u32) {
if in_hook() {
return;
}
with_hook_guard(|| {
send_event(&TraceEvent::Fork {
parent_pid,
child_pid,
});
});
}

/// Called from C constructor after library load. Sends an Exec event so the
/// launcher learns the command of newly exec'd processes.
#[cfg_attr(not(target_os = "macos"), no_mangle)]
pub unsafe extern "C" fn roar_preload_notify_exec() {
// Only send if ROAR_PRELOAD_TRACE_SOCK is set (i.e. we're inside a traced tree).
if trace_sock_path().is_none() {
return;
}
let pid = current_pid();
let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
return;
};
let command: Vec<String> = cmdline
.split(|b| *b == 0)
.filter(|s| !s.is_empty())
.map(|s| String::from_utf8_lossy(s).into_owned())
.collect();
if command.is_empty() {
return;
}
send_event(&TraceEvent::Exec { pid, command });
}

#[cfg_attr(not(target_os = "macos"), no_mangle)]
pub unsafe extern "C" fn roar_preload_emit_path_flags(path: *const c_char, flags: c_int) {
if in_hook() {
Expand Down
Loading