diff --git a/crates/flare-git-core/src/classify.rs b/crates/flare-git-core/src/classify.rs index 7ea1c969..31ae8930 100644 --- a/crates/flare-git-core/src/classify.rs +++ b/crates/flare-git-core/src/classify.rs @@ -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/", +]; /// `AGENTFLARE_GIT_TRUST_ROOT_PATHS`, comma-separated, appended to /// `TRUST_ROOT_PATHS` -- e.g. `".githooks/,policy.toml"`. Empty/unset -> @@ -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, diff --git a/src/mcp_prompts.rs b/src/mcp_prompts.rs index 340449d9..4021f531 100644 --- a/src/mcp_prompts.rs +++ b/src/mcp_prompts.rs @@ -62,6 +62,13 @@ pub fn list_prompts() -> Vec { "` ` to send (e.g. `codex review the API design above`), `inbox [me]`, or `thread ` (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 |audit |doctor plus options (omit for usage)", + )]), + ), ]; prompts.extend( SUB_SKILLS @@ -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)); } @@ -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 — 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 [] [--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 — 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." + )) +} + 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}")); @@ -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] @@ -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(¶ms, 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(¶ms, 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();