Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
406856f
feat(shim): add agentflare-shim crate — PATH-based lean-ctx dispatch …
getappz Jul 20, 2026
43d47a3
refactor(agentflare-shim): split into lib + bin, expose generic exec …
getappz Jul 20, 2026
568214b
feat(flare-git-core): new crate — shell primitives + branch resolution
getappz Jul 20, 2026
14abc4e
chore: update Cargo.lock for flare-git-core crate
getappz Jul 20, 2026
ff8b598
refactor: rewire crate::git:: consumers onto flare-git-core
getappz Jul 20, 2026
36f8008
refactor(worktree): move worktree mechanics into flare-git-core, dele…
getappz Jul 20, 2026
cad56c4
feat(flare-git-core): classify/snapshot/provenance/audit modules
getappz Jul 20, 2026
07777c9
feat(flare-git-shim): PATH shim binary + fix Windows self-resolution …
getappz Jul 20, 2026
8f5e9a1
feat(flare-git-shim): dogfood-enablement -- bypass env var + install/…
getappz Jul 20, 2026
c0a71e2
feat(classify): fail-open by default -- never block a subcommand it d…
getappz Jul 20, 2026
28edde1
fix(classify): deny branch -D/-M against the protected branch
getappz Jul 20, 2026
13364b9
feat(flare-git-core): configurable protected branches/trust-roots + c…
getappz Jul 20, 2026
9a93a58
feat(flare-git-shim): tiered bypass, snapshot toggle, canonical-repo …
getappz Jul 20, 2026
da2d9a3
feat(cli): snapshot list/restore/prune + provenance/ref-transaction h…
getappz Jul 20, 2026
6cb1872
fix(classify): push trust-root guard silently skipped for common invo…
getappz Jul 20, 2026
7dee4b3
fix(snapshot): force core.autocrlf=false on capture and restore
getappz Jul 20, 2026
df9ac95
fmt: bring the PR fully rustfmt-clean
getappz Jul 20, 2026
6525fd0
Merge branch 'master' into feat/flare-git-core-shim
getappz Jul 20, 2026
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
24 changes: 24 additions & 0 deletions .githooks/prepare-commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# agentflare provenance trailers (prepare-commit-msg).
#
# Appends Agentflare-Agent/Agentflare-Branch/Agentflare-Item trailers to
# every commit message, so it's clear afterward which agent (if any) made
# it and what work item it belongs to. Self-reported, not cryptographically
# attested -- matches every other AGENTFLARE_AGENT-based identity check in
# this codebase (see claims::owner_id). Idempotent: re-running on an
# already-stamped message (e.g. `commit --amend`) is a no-op.
#
# Fail-open by design: if the agentflare binary isn't on PATH or errors,
# the commit proceeds with its message unchanged.
#
# Installed into a project via `agentflare git install-hooks`, same as
# pre-commit/pre-push (agentflare stores the canonical copy under
# ~/.agentflare/githooks/).

msg_file="$1"

if command -v agentflare >/dev/null 2>&1; then
agentflare git trailer-inject "$msg_file" || true
fi

exit 0
31 changes: 31 additions & 0 deletions .githooks/reference-transaction
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# agentflare reference-transaction journal.
#
# Backstop audit trail independent of the git-shim's own interception: this
# fires for EVERY ref move in this repo, whether git was invoked through
# the agentflare git shim, bare git, or any other path -- unlike the shim's
# own audit log (crates/flare-git-core/src/audit.rs), which only sees
# invocations that actually went through it.
#
# Git invokes this hook once per state ("prepared", "committed", "aborted")
# with ref-update lines (`<old-oid> <new-oid> <refname>`) on stdin; only
# "committed" (the transaction that actually succeeded) is logged.
#
# Fail-open by design: if the agentflare binary isn't on PATH or errors,
# the underlying git operation is completely unaffected either way -- this
# hook cannot block anything, it only observes.
#
# Installed into a project via `agentflare git install-hooks`, same as
# pre-commit/pre-push/prepare-commit-msg.

state="$1"

if [ "$state" != "committed" ]; then
exit 0
fi

if command -v agentflare >/dev/null 2>&1; then
agentflare git ref-transaction-log || true
fi

exit 0
33 changes: 33 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["crates/flare-code", "crates/agent-registry", "crates/skill-registry", "crates/gateway-registry", "crates/flare-output", "crates/agentflare-artifacts", "crates/agentflare-backend", "crates/agentflare-db-kit", "crates/flare-search-kit", "crates/agentflare-store", "crates/flare-proxy"]
members = ["crates/flare-code", "crates/agent-registry", "crates/skill-registry", "crates/gateway-registry", "crates/flare-output", "crates/agentflare-artifacts", "crates/agentflare-backend", "crates/agentflare-db-kit", "crates/flare-search-kit", "crates/agentflare-store", "crates/flare-proxy", "crates/agentflare-shim", "crates/flare-git-core", "crates/flare-git-shim"]
resolver = "2"

[package]
Expand Down Expand Up @@ -79,6 +79,7 @@ tower-http = { version = "0.6", features = ["trace"] }
rust-embed = "8"
tokio-stream = { version = "0.1", features = ["sync"] }
agentflare-store = { path = "crates/agentflare-store" }
flare-git-core = { path = "crates/flare-git-core" }
flare-proxy = { path = "crates/flare-proxy" }

[target.'cfg(unix)'.dependencies]
Expand Down
23 changes: 23 additions & 0 deletions crates/agentflare-shim/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[package]
name = "agentflare-shim"
version = "0.1.0"
edition = "2024"
rust-version = "1.91"
description = "Compiled PATH shim routing AI-agent shell commands through lean-ctx -c, project-scoped via .agentflare"
license = "Apache-2.0"
publish = false

[[bin]]
name = "agentflare-shim"
path = "src/main.rs"

[dependencies]
which = "6"
dirs = "6"

[lints.rust]
unsafe_code = "warn"

[lints.clippy]
all = "warn"
pedantic = "warn"
86 changes: 86 additions & 0 deletions crates/agentflare-shim/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//! Generic PATH-shim exec plumbing, shared by any `agentflare-*` shim
//! binary: resolve the real target binary, exec it with argv/stdio
//! passthrough, and propagate its exit code. Tool-specific dispatch logic
//! (what to do BEFORE falling back to the real binary) lives in each shim
//! binary's own `main.rs`.

use std::env;
use std::ffi::OsString;
use std::path::Path;
use std::process::{Command, exit};

/// True if the named env var is set to a non-empty value.
#[must_use]
pub fn is_set(name: &str) -> bool {
env::var_os(name).is_some_and(|v| !v.is_empty())
}

/// Emits a trace line to stderr when `AGENTFLARE_SHIM_TRACE` is set.
pub fn trace(msg: &str) {
if is_set("AGENTFLARE_SHIM_TRACE") {
eprintln!("[flare-trace] {msg}");
}
}

/// `true` if any two adjacent path components are "target" followed by
/// "debug"/"release" -- a cargo build-profile directory (and everything
/// under it, e.g. `target/debug/deps`, `target/debug/build/*/out`).
/// Cargo prepends this to PATH for every test/run process (so build-script
/// DLLs resolve), and any `[[bin]]` target in the same cargo workspace
/// lands directly in it -- so during development/testing, a shim binary
/// built via cargo can find ANOTHER shim binary (or a differently-pathed
/// copy of itself, e.g. `target/debug/deps/git.exe` alongside
/// `target/debug/git.exe`) there instead of the real target. Not a
/// concern for an installed shim (only ever one file in
/// `~/.agentflare/shims/`), but a real hazard under `cargo test`.
fn is_cargo_target_profile_dir(p: &Path) -> bool {
let comps: Vec<_> = p.components().collect();
comps.windows(2).any(|w| {
w[0].as_os_str() == "target"
&& (w[1].as_os_str() == "debug" || w[1].as_os_str() == "release")
})
}

/// PATH with `shim_dir` removed, so a shim binary's own real-binary lookup
/// (and any child process it spawns) doesn't resolve back into itself --
/// also strips any cargo build-profile directory tree (see
/// `is_cargo_target_profile_dir`), which matters during development/
/// testing when multiple cargo-built binaries share one `target/debug`.
#[must_use]
pub fn path_without_shim_dir(shim_dir: &Path) -> Option<OsString> {
let path_var = env::var_os("PATH")?;
env::join_paths(
env::split_paths(&path_var).filter(|p| p != shim_dir && !is_cargo_target_profile_dir(p)),
)
.ok()
}

/// The tool name a shim binary is standing in for, derived from its own
/// filename (argv[0] / `current_exe`) -- e.g. a binary copied to `git` or
/// `git.exe` resolves to `"git"`.
pub fn tool_name_from_exe(exe: &Path) -> Option<String> {
exe.file_stem().and_then(|s| s.to_str()).map(str::to_string)
}

/// Resolve `tool` on `filtered_path` (or the current PATH if `None`), exec
/// it with argv/stdio forwarded, and exit with its exit code. Exits 127 if
/// the tool can't be found or fails to spawn -- never returns.
pub fn run_real(tool: &str, filtered_path: Option<&OsString>, args: &[OsString]) -> ! {
trace(&format!("real: {tool}"));
let cwd = env::current_dir().unwrap_or_default();
let resolved = match filtered_path {
Some(p) => which::which_in(tool, Some(p), cwd),
None => which::which(tool),
};
let Ok(real) = resolved else {
eprintln!("agentflare-shim: command not found: {tool}");
exit(127);
};
match Command::new(real).args(args).status() {
Ok(status) => exit(status.code().unwrap_or(1)),
Err(e) => {
eprintln!("agentflare-shim: failed to exec {tool}: {e}");
exit(127)
}
}
}
111 changes: 111 additions & 0 deletions crates/agentflare-shim/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//! Compiled PATH shim for AI-agent shell sessions -- cross-platform, same
//! pattern as mise's `crates/mise-shim`: one small binary, copied/hardlinked
//! under many tool names (git, cargo, git.exe, cargo.exe, ...) into
//! `~/.agentflare/shims/`, prepended to PATH. Each copy reads its own
//! filename (argv[0] / `current_exe`) to learn which tool it stands in for,
//! then either routes the call through `lean-ctx -c <tool>` or execs the
//! real binary untouched. Bundled alongside the main `agentflare` binary at
//! release time, same as mise bundles `mise-shim`.
//!
//! On Windows this is the only mechanism that reaches agent tool calls at
//! all: Claude Code's PowerShell tool runs
//! `pwsh.exe -NoProfile -NonInteractive -Command "..."`, and `-NoProfile`
//! skips `$PROFILE` entirely, so anything gated behind a shell profile
//! (lean-ctx's own `shell-hook.ps1`, or a shell-function approach) never
//! loads. PATH resolution of bare command names inside the `-Command`
//! payload happens regardless of `-NoProfile`, so a shim directory on PATH
//! still gets hit. On Unix this plays the same role a `.bashenv` function
//! would -- same gate logic, just compiled instead of shell.
//!
//! Gate order: kill switches -> agent-env marker -> `.agentflare` project
//! walk-up (stopping at `$HOME`, since `~/.agentflare` is agentflare's own
//! app-data dir, not a project marker -- a false-positive bug found and
//! fixed on the bash-function prototype of this same idea). This is for AI
//! agent CLIs only: anything outside that double gate must resolve to the
//! real binary, unmodified, with negligible overhead, since a shim dir on
//! PATH affects every process that resolves through it, system-wide.

use std::env;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Command, exit};

use agentflare_shim::{is_set, path_without_shim_dir, run_real, tool_name_from_exe, trace};

const KILL_SWITCHES: &[&str] = &["LEAN_CTX_DISABLED", "LEAN_CTX_NO_HOOK"];

const AGENT_ENV_VARS: &[&str] = &[
"LEAN_CTX_AGENT",
"CLAUDECODE",
"CURSOR_AGENT",
"CODEX_CLI_SESSION",
"GEMINI_SESSION",
"CODEBUDDY",
];

const PROJECT_MARKER: &str = ".agentflare";

fn any_set(names: &[&str]) -> bool {
names.iter().any(|n| is_set(n))
}

/// Walk up from `start` looking for `.agentflare`, stopping at `home`
/// (exclusive) -- `~/.agentflare` is agentflare's own data dir, not a
/// project marker, and would otherwise false-positive on everything
/// under the user's home directory.
fn in_scoped_project(start: &Path, home: Option<&Path>) -> bool {
let mut dir = Some(start);
while let Some(d) = dir {
if home.is_some_and(|h| h == d) {
return false;
}
if d.join(PROJECT_MARKER).exists() {
return true;
}
dir = d.parent();
}
false
}

fn main() {
let exe = match env::current_exe() {
Ok(p) => p,
Err(e) => {
eprintln!("agentflare-shim: failed to determine executable path: {e}");
exit(1);
}
};
let Some(tool) = tool_name_from_exe(&exe) else {
eprintln!("agentflare-shim: failed to determine tool name from executable path");
exit(1);
};
let shim_dir: PathBuf = exe.parent().map_or_else(PathBuf::new, Path::to_path_buf);
let args: Vec<OsString> = env::args_os().skip(1).collect();
let filtered_path = path_without_shim_dir(&shim_dir);

if any_set(KILL_SWITCHES) || !any_set(AGENT_ENV_VARS) {
run_real(&tool, filtered_path.as_ref(), &args);
}

let cwd = env::current_dir().unwrap_or_default();
if !in_scoped_project(&cwd, dirs::home_dir().as_deref()) {
run_real(&tool, filtered_path.as_ref(), &args);
}

trace(&format!("dispatch: lean-ctx -c {tool}"));
let mut cmd = Command::new("lean-ctx");
cmd.arg("-c").arg(&tool).args(&args);
if let Some(p) = &filtered_path {
cmd.env("PATH", p);
}
match cmd.status() {
Ok(status) => {
let code = status.code().unwrap_or(1);
if code == 126 || code == 127 {
run_real(&tool, filtered_path.as_ref(), &args);
}
exit(code);
}
Err(_) => run_real(&tool, filtered_path.as_ref(), &args),
}
}
21 changes: 21 additions & 0 deletions crates/flare-git-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "flare-git-core"
version = "0.1.0"
edition = "2024"
rust-version = "1.91"
license = "Apache-2.0"
description = "Local git primitives, worktree management, and PATH-shim policy (classify/snapshot/provenance/audit) for agentflare -- single source of truth, consumed by the CLI, MCP server, and the git PATH shim."
publish = false

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4"
rusqlite = { version = "0.40", features = ["bundled"] }
agentflare-backend = { package = "agentflare-backend", path = "../agentflare-backend" }
dirs = "6"
agent-detector = "0.2.1"
which = "6"

[dev-dependencies]
tempfile = "3"
Loading
Loading