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
29 changes: 26 additions & 3 deletions crates/flare-git-core/src/classify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,30 @@ pub struct Event {

/// Trust-root paths a `push` must never carry changes to — agentflare's own
/// enforcement config, not something an agent should be able to push a
/// change to and quietly weaken.
pub(crate) const TRUST_ROOT_PATHS: &[&str] = &[".githooks/", ".agentflare/", "Cargo.toml"];
/// change to and quietly weaken. CI workflow config is included for the same
/// reason as `.githooks/`/`.agentflare/`: an agent that can silently rewrite
/// the pipeline that's supposed to catch its own mistakes has defeated that
/// check before it ever runs (the "wipes CI" scenario named in the
/// competitor audit that led to this addition).
///
/// Known limitation (confirmed empirically 2026-07-29, see item #321):
/// `resolve_trust_root_touch` diffs the pushed branch against the default
/// branch by NAME — when both are literally "master" (the ordinary bare
/// `git push`/`git push origin master` case), that's a self-diff and always
/// resolves `Clean`, so a direct push of the default branch is denied by the
/// blanket "can't push the default branch" rule regardless of what changed,
/// never by this list. This entry only changes the deny *message* once a
/// pushed branch legitimately diverges under a different name (feature
/// branch, or after #321's src:dest refspec fix) — it doesn't add new
/// protection against the direct-push case, which was already fully blocked.
pub(crate) const TRUST_ROOT_PATHS: &[&str] = &[
".githooks/",
".agentflare/",
"Cargo.toml",
".github/workflows/",
".gitlab-ci.yml",
".circleci/",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
];

/// `AGENTFLARE_GIT_TRUST_ROOT_PATHS`, comma-separated, appended to
/// `TRUST_ROOT_PATHS` -- e.g. `".githooks/,policy.toml"`. Empty/unset ->
Expand Down Expand Up @@ -300,7 +322,8 @@ pub fn classify_pure(
TrustRootTouch::Touched(_) => Disposition::Passthrough,
TrustRootTouch::Unknown if push_targets_default_branch => Disposition::Deny {
reason: format!(
"this push's diff against trust-root paths (.githooks/, .agentflare/, Cargo.toml) could not be verified, and it targets the repo's default branch '{default_branch}' — blocked by the agentflare git shim as a precaution."
"this push's diff against trust-root paths ({}) could not be verified, and it targets the repo's default branch '{default_branch}' — blocked by the agentflare git shim as a precaution.",
policy.trust_root_paths.join(", ")
),
},
TrustRootTouch::Unknown => Disposition::Passthrough,
Expand Down
118 changes: 116 additions & 2 deletions src/mcp_prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ pub fn list_prompts() -> Vec<Prompt> {
"`<recipient> <brief>` to send (e.g. `codex review the API design above`), `inbox [me]`, or `thread <id>` (omit for usage)",
)]),
),
Prompt::new(
"git",
Some("Recovery snapshots, worktree audit, and health checks from the agentflare git shim"),
Some(vec![PromptArgument::new("command").with_description(
"install-hooks|install-shim|uninstall-shim|snapshot <list|restore|prune>|audit <preview|prune>|doctor plus options (omit for usage)",
)]),
),
];
prompts.extend(
SUB_SKILLS
Expand All @@ -81,6 +88,9 @@ pub fn get_prompt(
if request.name == "handoff" {
return Some(get_handoff_command(request, agent));
}
if request.name == "git" {
return Some(get_git_command(request));
}
if request.name == "optimize" {
return Some(get_optimize_mode(request));
}
Expand Down Expand Up @@ -235,6 +245,66 @@ fn get_handoff_command(request: &GetPromptRequestParams, agent: Option<&str>) ->
))
}

/// Called automatically by git hooks/the shim itself; take no useful direct
/// input from a human or agent invocation, so `get_git_command` refuses to
/// echo a run instruction for them even if typed in directly.
const HIDDEN_GIT_SUBCOMMANDS: &[&str] = &["trailer-inject", "ref-transaction-log", "scope-check"];

/// (Internal/hidden `agentflare git` subcommands — see
/// `HIDDEN_GIT_SUBCOMMANDS` — are deliberately not surfaced in the usage
/// card and rejected below if typed directly. Ordinary git commands
/// (status/log/diff/commit/push/branch/...) and other agentflare CLI/MCP
/// surfaces like pr_check are deliberately NOT duplicated here either —
/// those already run via the `!` shell-escape or their own MCP tool; this
/// prompt only covers the agentflare-specific git-shim admin subcommands
/// that have no other entrypoint.)
fn get_git_command(request: &GetPromptRequestParams) -> GetPromptResult {
let command = request
.arguments
.as_ref()
.and_then(|a| a.get("command"))
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();

if command.is_empty() {
return assistant_text(
"agentflare git-shim commands — pass as this command's argument.\n\
install-hooks [--yes] — install branch-protection/provenance git hooks into this repo\n\
install-shim --binary <path> — install the flare-git-shim binary as `git` on PATH (dogfooding/local use)\n\
uninstall-shim — remove a previously installed git shim\n\
snapshot list — list recovery snapshots for this repo, newest first\n\
snapshot restore [<id>] [--yes] — restore a snapshot's files into the working tree (non-destructive)\n\
snapshot prune [--keep N] — delete all but the N most recent snapshots (default 5)\n\
audit preview — list orphaned worktree directories\n\
audit prune <names...|--all> — remove orphaned worktree directories (snapshots taken first)\n\
doctor [--format text|json|markdown] [--reclaim] [--force] [--staleness-days N] — health sweep over all claim worktrees",
);
}

let first_word = command.split_whitespace().next().unwrap_or("");
if HIDDEN_GIT_SUBCOMMANDS.contains(&first_word) {
return assistant_text(format!(
"`{first_word}` is an internal agentflare git-shim subcommand invoked automatically \
by git hooks/the shim itself — it isn't meant for direct human or agent invocation, \
so it won't be run from here. See the bare `/git` usage card for the supported \
subcommands."
));
}

assistant_text(format!(
"agentflare git command requested: `{command}`\n\n\
Run it as `agentflare git {command}` via the project's shell tool (ctx_shell if \
lean-ctx is available through the flare gateway, else Bash), then report its output \
to the user. These are the underlying git-shim CLI subcommands directly — install-hooks, \
install-shim, uninstall-shim, snapshot list/restore/prune, audit preview/prune, and doctor \
(see the bare `/git` usage card for each one's options). `doctor --reclaim` and \
`audit prune`/`snapshot prune` mutate local state (worktrees/snapshots) — confirm with \
the user before running those if it wasn't clearly what they asked for."
Comment on lines +261 to +304

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate commands before generating a shell instruction.

command is interpolated verbatim into agentflare git {command}. Inputs such as snapshot list; ... can escape into a second shell command, and scope-check can surface a hidden internal subcommand. Parse/allowlist the supported admin grammar and reject invalid input before rendering the execution instruction; add rejection tests for shell metacharacters and hidden commands.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mcp_prompts.rs` around lines 257 - 290, Validate the parsed command in
get_git_command before generating assistant_text: parse and allowlist only the
documented git-shim subcommands and their supported arguments, rejecting shell
metacharacters, malformed grammar, and hidden commands such as scope-check.
Render the execution instruction only for validated commands, and add rejection
tests covering command injection and unsupported hidden commands.

))
}

fn get_optimize_skill(skill: &str) -> GetPromptResult {
if let Err(e) = crate::optimize::code::set_active(skill) {
return assistant_text(format!("Failed to persist flare code mode: {e}"));
Expand All @@ -254,8 +324,8 @@ mod tests {
assert!(names.contains(&"optimize"));
assert!(names.contains(&"optimize-review"));
assert!(names.contains(&"optimize-no-hallucination"));
// optimize + artifact + handoff + one per sub-skill
assert_eq!(names.len(), 3 + SUB_SKILLS.len());
// optimize + artifact + handoff + git + one per sub-skill
assert_eq!(names.len(), 4 + SUB_SKILLS.len());
}

#[test]
Expand Down Expand Up @@ -385,6 +455,50 @@ mod tests {
assert!(text.contains("`all`"), "{text}");
}

#[test]
fn lists_git_prompt() {
let prompts = list_prompts();
assert!(prompts.iter().any(|p| p.name == "git"));
}

#[test]
fn bare_git_prompt_returns_usage() {
let result = get_prompt(&GetPromptRequestParams::new("git"), None).unwrap();
let text = format!("{:?}", result.messages[0].content);
assert!(text.contains("install-hooks"), "{text}");
assert!(text.contains("snapshot"), "{text}");
assert!(text.contains("doctor"), "{text}");
// Internal/hidden subcommands must never be surfaced to a user.
assert!(!text.contains("scope-check"), "{text}");
assert!(!text.contains("trailer-inject"), "{text}");
}

#[test]
fn git_prompt_embeds_command_and_shell_instruction() {
use rmcp::model::JsonObject;
let mut args = JsonObject::new();
args.insert("command".to_string(), serde_json::json!("snapshot list"));
let params = GetPromptRequestParams::new("git").with_arguments(args);
let result = get_prompt(&params, None).unwrap();
let text = format!("{:?}", result.messages[0].content);
assert!(text.contains("snapshot list"), "{text}");
assert!(text.contains("agentflare git snapshot list"), "{text}");
}

#[test]
fn git_prompt_rejects_hidden_subcommands() {
use rmcp::model::JsonObject;
for hidden in ["scope-check", "trailer-inject", "ref-transaction-log"] {
let mut args = JsonObject::new();
args.insert("command".to_string(), serde_json::json!(hidden));
let params = GetPromptRequestParams::new("git").with_arguments(args);
let result = get_prompt(&params, None).unwrap();
let text = format!("{:?}", result.messages[0].content);
assert!(text.contains("isn't meant for direct"), "{text}");
assert!(!text.contains("Run it as"), "{text}");
}
}

#[test]
fn optimize_review_returns_full_skill_body() {
let result = get_prompt(&GetPromptRequestParams::new("optimize-review"), None).unwrap();
Expand Down
Loading