diff --git a/.gitignore b/.gitignore
index f04f168ed..e0c07ad92 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,4 @@ typesense-data/
 # Hermit (toolchain manager cache)
 .hermit/
 doc/
+repos/
diff --git a/Cargo.lock b/Cargo.lock
index 2a97b9d8e..148fd5db5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3911,11 +3911,13 @@ dependencies = [
  "deadpool-redis",
  "futures-util",
  "hex",
+ "hmac 0.13.0",
  "infer",
  "metrics",
  "metrics-exporter-prometheus",
  "moka",
  "nostr",
+ "rand 0.10.0",
  "redis",
  "serde",
  "serde_json",
@@ -3930,6 +3932,7 @@ dependencies = [
  "sprout-search",
  "sprout-workflow",
  "sqlx",
+ "subtle",
  "thiserror 2.0.18",
  "tokio",
  "tokio-util",
diff --git a/crates/git-credential-nostr/src/main.rs b/crates/git-credential-nostr/src/main.rs
index 83e1b97c1..59be42f51 100644
--- a/crates/git-credential-nostr/src/main.rs
+++ b/crates/git-credential-nostr/src/main.rs
@@ -180,10 +180,14 @@ fn main() {
     let method = parse_method(wwwauth)
         .unwrap_or_else(|| fail("server did not include method hint in WWW-Authenticate"));
 
-    // Sign the repo root URL, not the full endpoint URL.
-    // Git's credential helper is invoked once (for info/refs) and the token is reused
-    // for subsequent requests (upload-pack, receive-pack). The server verifies against
-    // the canonical repo root, so we strip endpoint suffixes here.
+    // Sign the repo root URL — strip endpoint suffixes to get the canonical form.
+    //
+    // Git's credential helper is invoked once (for the initial info/refs GET) and the
+    // token is reused for subsequent requests (upload-pack, receive-pack POST). The
+    // server verifies against the bare repo root URL.
+    //
+    // Git's credential protocol does NOT pass query strings in the `path` field, so
+    // we never see `?service=...` here — just the path component.
     let repo_path = path
         .split_once("/info/refs")
         .map(|(prefix, _)| prefix)
diff --git a/crates/sprout-core/src/channel.rs b/crates/sprout-core/src/channel.rs
index 74e1366ba..e2caffc2a 100644
--- a/crates/sprout-core/src/channel.rs
+++ b/crates/sprout-core/src/channel.rs
@@ -96,6 +96,10 @@ impl FromStr for ChannelType {
 // ── Member role ──────────────────────────────────────────────────────────────
 
 /// A member's role within a channel.
+///
+/// The hierarchy for permission checks is: Owner > Admin > Member > Guest.
+/// Bot is a **separate designation** — it is not part of the linear hierarchy.
+/// Use [`MemberRole::permission_level`] for numeric comparisons in authorization.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum MemberRole {
     /// Full control — can manage members and delete the channel.
@@ -106,7 +110,7 @@ pub enum MemberRole {
     Member,
     /// Read-only external participant.
     Guest,
-    /// Automated agent or integration.
+    /// Automated agent or integration (not in the role hierarchy).
     Bot,
 }
 
@@ -126,6 +130,27 @@ impl MemberRole {
     pub fn is_elevated(&self) -> bool {
         matches!(self, Self::Owner | Self::Admin)
     }
+
+    /// Numeric permission level for authorization comparisons.
+    ///
+    /// Higher = more privileged. Bot returns 0 (must use explicit grants).
+    /// Use `role.permission_level() >= required.permission_level()` for checks.
+    pub fn permission_level(self) -> u8 {
+        match self {
+            Self::Owner => 4,
+            Self::Admin => 3,
+            Self::Member => 2,
+            Self::Guest => 1,
+            Self::Bot => 0,
+        }
+    }
+
+    /// Returns true if this role meets or exceeds the required role's permission level.
+    ///
+    /// Bot never meets any requirement (returns false for all non-Bot requirements).
+    pub fn has_at_least(self, required: MemberRole) -> bool {
+        self.permission_level() >= required.permission_level()
+    }
 }
 
 impl fmt::Display for MemberRole {
diff --git a/crates/sprout-core/src/git_perms.rs b/crates/sprout-core/src/git_perms.rs
new file mode 100644
index 000000000..865462e36
--- /dev/null
+++ b/crates/sprout-core/src/git_perms.rs
@@ -0,0 +1,1029 @@
+//! Git permission types — ref patterns, protection rules, and policy evaluation inputs.
+//!
+//! This module defines the core data types for the Sprout git permission system.
+//! The permission model: channel role = repo role; `sprout-protect` tags on
+//! kind:30617 add constraints that apply to everyone (including the owner).
+//!
+//! # Architecture
+//!
+//! ```text
+//! kind:30617 tags → parse → Vec<ProtectionRule>
+//!                                    ↓
+//! push arrives → classify refs → match patterns → union rules → enforce
+//! ```
+
+use crate::channel::MemberRole;
+use std::fmt;
+
+// ── Limits (DoS prevention for untrusted kind:30617 input) ───────────────────
+
+/// Maximum number of `sprout-protect` tags per repo.
+pub const MAX_PROTECTION_RULES: usize = 50;
+/// Maximum character length of a ref pattern.
+pub const MAX_PATTERN_LENGTH: usize = 256;
+/// Maximum number of wildcard segments per pattern.
+pub const MAX_WILDCARDS_PER_PATTERN: usize = 3;
+
+// ── Ref Pattern ──────────────────────────────────────────────────────────────
+
+/// A validated ref pattern for matching git refs.
+///
+/// Grammar: `segment ("/" segment)*` where segment is either a literal
+/// `[a-zA-Z0-9._-]+` or `*` (matches exactly one path segment).
+///
+/// Patterns MUST start with `refs/`. No `**`, `?`, `[...]`, or partial globs.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RefPattern {
+    /// The original pattern string (e.g., "refs/heads/*").
+    raw: String,
+    /// Pre-split segments for matching.
+    segments: Vec<PatternSegment>,
+}
+
+/// A single segment in a ref pattern.
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum PatternSegment {
+    /// Matches exactly this literal string.
+    Literal(String),
+    /// Matches any single path segment.
+    Wildcard,
+    /// Matches one or more path segments (recursive). Must be the last segment.
+    RecursiveWildcard,
+}
+
+/// Errors from parsing a ref pattern.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum PatternError {
+    /// Pattern is empty.
+    Empty,
+    /// Pattern exceeds maximum length.
+    TooLong,
+    /// Pattern doesn't start with `refs/`.
+    MissingRefsPrefix,
+    /// A segment contains invalid characters or is a partial glob.
+    InvalidSegment(String),
+    /// Too many wildcard segments.
+    TooManyWildcards,
+}
+
+impl fmt::Display for PatternError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Empty => write!(f, "pattern is empty"),
+            Self::TooLong => write!(f, "pattern exceeds {MAX_PATTERN_LENGTH} chars"),
+            Self::MissingRefsPrefix => write!(f, "pattern must start with 'refs/'"),
+            Self::InvalidSegment(s) => write!(f, "invalid segment: {s:?}"),
+            Self::TooManyWildcards => {
+                write!(f, "pattern exceeds {MAX_WILDCARDS_PER_PATTERN} wildcards")
+            }
+        }
+    }
+}
+
+impl std::error::Error for PatternError {}
+
+impl RefPattern {
+    /// Parse and validate a ref pattern string.
+    pub fn parse(pattern: &str) -> Result<Self, PatternError> {
+        if pattern.is_empty() {
+            return Err(PatternError::Empty);
+        }
+        if pattern.len() > MAX_PATTERN_LENGTH {
+            return Err(PatternError::TooLong);
+        }
+        if !pattern.starts_with("refs/") {
+            return Err(PatternError::MissingRefsPrefix);
+        }
+
+        let mut segments = Vec::new();
+        let mut wildcard_count = 0;
+
+        let parts: Vec<&str> = pattern.split('/').collect();
+        for (i, part) in parts.iter().enumerate() {
+            if *part == "**" {
+                // `**` must be the last segment (recursive match).
+                if i != parts.len() - 1 {
+                    return Err(PatternError::InvalidSegment(
+                        "** must be the last segment".to_string(),
+                    ));
+                }
+                wildcard_count += 1;
+                if wildcard_count > MAX_WILDCARDS_PER_PATTERN {
+                    return Err(PatternError::TooManyWildcards);
+                }
+                segments.push(PatternSegment::RecursiveWildcard);
+            } else if *part == "*" {
+                wildcard_count += 1;
+                if wildcard_count > MAX_WILDCARDS_PER_PATTERN {
+                    return Err(PatternError::TooManyWildcards);
+                }
+                segments.push(PatternSegment::Wildcard);
+            } else if part.is_empty() {
+                return Err(PatternError::InvalidSegment(String::new()));
+            } else if part.contains('*')
+                || part.contains('?')
+                || part.contains('[')
+                || part.contains(']')
+            {
+                // Partial globs (e.g., "v*") are not allowed.
+                return Err(PatternError::InvalidSegment(part.to_string()));
+            } else if !part
+                .chars()
+                .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
+            {
+                return Err(PatternError::InvalidSegment(part.to_string()));
+            } else {
+                segments.push(PatternSegment::Literal(part.to_string()));
+            }
+        }
+
+        Ok(Self {
+            raw: pattern.to_string(),
+            segments,
+        })
+    }
+
+    /// Test whether this pattern matches a given ref name.
+    ///
+    /// Matching is segment-by-segment:
+    /// - `*` matches exactly one path segment
+    /// - `**` (must be last) matches one or more remaining segments
+    pub fn matches(&self, ref_name: &str) -> bool {
+        let ref_segments: Vec<&str> = ref_name.split('/').collect();
+
+        // Check for recursive wildcard (must be last segment).
+        if let Some(PatternSegment::RecursiveWildcard) = self.segments.last() {
+            let prefix_len = self.segments.len() - 1;
+            // Ref must have at least as many segments as the prefix (+ 1 for the **)
+            if ref_segments.len() <= prefix_len {
+                return false;
+            }
+            // All prefix segments must match.
+            return self.segments[..prefix_len]
+                .iter()
+                .zip(ref_segments[..prefix_len].iter())
+                .all(|(pat, seg)| match pat {
+                    PatternSegment::Wildcard => true,
+                    PatternSegment::Literal(lit) => lit == *seg,
+                    PatternSegment::RecursiveWildcard => unreachable!(),
+                });
+        }
+
+        // Non-recursive: exact segment count match required.
+        if ref_segments.len() != self.segments.len() {
+            return false;
+        }
+        self.segments
+            .iter()
+            .zip(ref_segments.iter())
+            .all(|(pat, seg)| match pat {
+                PatternSegment::Wildcard => true,
+                PatternSegment::Literal(lit) => lit == *seg,
+                PatternSegment::RecursiveWildcard => unreachable!(),
+            })
+    }
+
+    /// The raw pattern string.
+    pub fn as_str(&self) -> &str {
+        &self.raw
+    }
+}
+
+impl fmt::Display for RefPattern {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(&self.raw)
+    }
+}
+
+// ── Update Classification ────────────────────────────────────────────────────
+
+/// The type of ref update in a push.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum UpdateKind {
+    /// New ref (old_oid is zero).
+    Create,
+    /// Existing ref updated, new commit is a descendant of old (fast-forward).
+    FastForward,
+    /// Existing ref updated, new commit is NOT a descendant of old.
+    NonFastForward,
+    /// Ref deleted (new_oid is zero).
+    Delete,
+}
+
+impl UpdateKind {
+    /// Classify a ref update from old/new OIDs.
+    ///
+    /// `is_ancestor` should be the result of `git merge-base --is-ancestor old new`.
+    /// For creates/deletes, the value is ignored.
+    pub fn classify(old_oid: &str, new_oid: &str, is_ancestor: bool) -> Self {
+        const ZERO_OID: &str = "0000000000000000000000000000000000000000";
+        if old_oid == ZERO_OID {
+            Self::Create
+        } else if new_oid == ZERO_OID {
+            Self::Delete
+        } else if is_ancestor {
+            Self::FastForward
+        } else {
+            Self::NonFastForward
+        }
+    }
+}
+
+/// A single ref update within a push.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RefUpdate {
+    /// The ref being updated (e.g., "refs/heads/main").
+    pub ref_name: String,
+    /// The type of update.
+    pub kind: UpdateKind,
+    /// Old OID (hex, 40 chars). Zero OID for creates.
+    pub old_oid: String,
+    /// New OID (hex, 40 chars). Zero OID for deletes.
+    pub new_oid: String,
+}
+
+// ── Protection Rules ─────────────────────────────────────────────────────────
+
+/// A single protection rule parsed from a `sprout-protect` tag on kind:30617.
+///
+/// Format: `["sprout-protect", "<ref-pattern>", "<rule>", ...]`
+/// Multiple rules per tag are allowed.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ProtectionRule {
+    /// The ref pattern this rule applies to.
+    pub pattern: RefPattern,
+    /// Minimum role required to push (if specified).
+    pub push_role: Option<MemberRole>,
+    /// Whether non-fast-forward updates are forbidden.
+    pub no_force_push: bool,
+    /// Whether ref deletion is forbidden.
+    pub no_delete: bool,
+    /// Whether direct push is denied (must use NIP-34 patch).
+    ///
+    /// NOTE: This blocks ALL ref update kinds (create, FF, NFF, delete) — not just
+    /// fast-forward pushes. If set on a ref pattern, that ref can only be modified
+    /// via the NIP-34 patch workflow. This is intentional: the ref is fully governed
+    /// by the patch review process.
+    pub require_patch: bool,
+}
+
+/// Errors from parsing a `sprout-protect` tag.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RuleParseError {
+    /// Tag has fewer than 2 values (need at least pattern + one rule).
+    TooFewValues,
+    /// Too many protection rules on this repo.
+    TooManyRules,
+    /// Invalid ref pattern.
+    InvalidPattern(PatternError),
+    /// Unknown rule string.
+    UnknownRule(String),
+    /// Invalid role in `push:<role>`.
+    InvalidRole(String),
+}
+
+impl fmt::Display for RuleParseError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::TooFewValues => write!(f, "sprout-protect tag needs pattern + at least one rule"),
+            Self::TooManyRules => write!(f, "exceeds max {MAX_PROTECTION_RULES} rules per repo"),
+            Self::InvalidPattern(e) => write!(f, "invalid pattern: {e}"),
+            Self::UnknownRule(r) => write!(f, "unknown rule: {r:?}"),
+            Self::InvalidRole(r) => write!(f, "invalid role in push rule: {r:?}"),
+        }
+    }
+}
+
+impl std::error::Error for RuleParseError {}
+
+/// Parse a single `sprout-protect` tag into a `ProtectionRule`.
+///
+/// Tag format: `["sprout-protect", "<pattern>", "<rule1>", "<rule2>", ...]`
+/// The first element ("sprout-protect") should already be stripped — pass
+/// the remaining values starting with the pattern.
+/// Parse a single `sprout-protect` tag (simple API, discards unknown rules).
+pub fn parse_protection_tag(values: &[&str]) -> Result<ProtectionRule, RuleParseError> {
+    let (rule, _unknowns) = parse_protection_tag_with_warnings(values)?;
+    Ok(rule)
+}
+
+/// Parse a single `sprout-protect` tag, returning unknown rules for logging.
+pub fn parse_protection_tag_with_warnings(
+    values: &[&str],
+) -> Result<(ProtectionRule, Vec<String>), RuleParseError> {
+    if values.len() < 2 {
+        return Err(RuleParseError::TooFewValues);
+    }
+
+    let pattern = RefPattern::parse(values[0]).map_err(RuleParseError::InvalidPattern)?;
+
+    let mut push_role: Option<MemberRole> = None;
+    let mut no_force_push = false;
+    let mut no_delete = false;
+    let mut require_patch = false;
+    let mut unknown_rules = Vec::new();
+
+    for &rule_str in &values[1..] {
+        if let Some(role_str) = rule_str.strip_prefix("push:") {
+            let role: MemberRole = role_str
+                .parse()
+                .map_err(|_| RuleParseError::InvalidRole(role_str.to_string()))?;
+            // Reject push:bot and push:guest — nonsensical rules.
+            // Bot is promoted to Member at the policy layer; push:bot is meaningless.
+            // Guest cannot push regardless; push:guest would be confusing.
+            if matches!(role, MemberRole::Bot | MemberRole::Guest) {
+                return Err(RuleParseError::InvalidRole(role_str.to_string()));
+            }
+            // Take the strictest (highest permission level).
+            push_role = Some(match push_role {
+                None => role,
+                Some(existing) => {
+                    if role.permission_level() > existing.permission_level() {
+                        role
+                    } else {
+                        existing
+                    }
+                }
+            });
+        } else {
+            match rule_str {
+                "no-force-push" => no_force_push = true,
+                "no-delete" => no_delete = true,
+                "require-patch" => require_patch = true,
+                // Forward-compatibility: unknown rules are skipped but reported.
+                other => unknown_rules.push(other.to_string()),
+            }
+        }
+    }
+
+    Ok((
+        ProtectionRule {
+            pattern,
+            push_role,
+            no_force_push,
+            no_delete,
+            require_patch,
+        },
+        unknown_rules,
+    ))
+}
+
+/// Result of parsing protection tags — includes rules and any warnings.
+#[derive(Debug, Clone)]
+pub struct ParsedProtection {
+    /// Successfully parsed protection rules.
+    pub rules: Vec<ProtectionRule>,
+    /// Unknown rule strings that were skipped (potential typos or future rules).
+    /// Callers should log these as warnings.
+    pub unknown_rules: Vec<String>,
+}
+
+/// Parse all `sprout-protect` tags from a kind:30617 event's tag list.
+///
+/// Returns an error if any `sprout-protect` tag is structurally malformed.
+/// Unknown rule strings are skipped but reported in `ParsedProtection::unknown_rules`
+/// so callers can log warnings (helps catch typos while maintaining forward-compat).
+/// Enforces the per-repo rule count limit.
+pub fn parse_protection_tags(tags: &[Vec<String>]) -> Result<ParsedProtection, RuleParseError> {
+    let mut rules = Vec::new();
+    let mut unknown_rules = Vec::new();
+
+    for tag in tags {
+        if tag.first().map(|s| s.as_str()) != Some("sprout-protect") {
+            continue;
+        }
+        if rules.len() >= MAX_PROTECTION_RULES {
+            return Err(RuleParseError::TooManyRules);
+        }
+        let values: Vec<&str> = tag[1..].iter().map(|s| s.as_str()).collect();
+        let (rule, unknowns) = parse_protection_tag_with_warnings(&values)?;
+        rules.push(rule);
+        unknown_rules.extend(unknowns);
+    }
+
+    Ok(ParsedProtection {
+        rules,
+        unknown_rules,
+    })
+}
+
+// ── Built-in Defaults ────────────────────────────────────────────────────────
+
+/// Built-in default minimum role for an operation when no `sprout-protect` tag matches.
+pub fn default_min_role(ref_name: &str, kind: UpdateKind) -> MemberRole {
+    let is_branch = ref_name.starts_with("refs/heads/");
+    let is_tag = ref_name.starts_with("refs/tags/");
+
+    match kind {
+        UpdateKind::Create => {
+            if is_branch || is_tag {
+                MemberRole::Member
+            } else {
+                MemberRole::Admin
+            }
+        }
+        UpdateKind::FastForward => {
+            if is_branch {
+                MemberRole::Member
+            } else if is_tag {
+                // Tag "move" (overwrite) = Admin.
+                MemberRole::Admin
+            } else {
+                MemberRole::Admin
+            }
+        }
+        UpdateKind::NonFastForward => MemberRole::Admin,
+        UpdateKind::Delete => MemberRole::Admin,
+    }
+}
+
+// ── Effective Rules (union of all matching patterns) ─────────────────────────
+
+/// The effective constraints for a ref after unioning all matching rules.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct EffectiveRules {
+    /// Strictest `push:<role>` from all matching patterns (if any).
+    pub push_role: Option<MemberRole>,
+    /// Whether non-fast-forward is forbidden (any match sets this).
+    pub no_force_push: bool,
+    /// Whether deletion is forbidden (any match sets this).
+    pub no_delete: bool,
+    /// Whether direct push is denied (any match sets this).
+    pub require_patch: bool,
+    /// Whether any explicit rule matched (vs. using defaults).
+    pub has_explicit_match: bool,
+}
+
+impl EffectiveRules {
+    /// Compute effective rules by unioning all protection rules that match a ref.
+    pub fn for_ref(ref_name: &str, rules: &[ProtectionRule]) -> Self {
+        let mut push_role: Option<MemberRole> = None;
+        let mut no_force_push = false;
+        let mut no_delete = false;
+        let mut require_patch = false;
+        let mut has_explicit_match = false;
+
+        for rule in rules {
+            if !rule.pattern.matches(ref_name) {
+                continue;
+            }
+            has_explicit_match = true;
+
+            // Union: take strictest push role.
+            if let Some(role) = rule.push_role {
+                push_role = Some(match push_role {
+                    None => role,
+                    Some(existing) => {
+                        if role.permission_level() > existing.permission_level() {
+                            role
+                        } else {
+                            existing
+                        }
+                    }
+                });
+            }
+
+            // Union: any match sets these flags.
+            no_force_push = no_force_push || rule.no_force_push;
+            no_delete = no_delete || rule.no_delete;
+            require_patch = require_patch || rule.require_patch;
+        }
+
+        Self {
+            push_role,
+            no_force_push,
+            no_delete,
+            require_patch,
+            has_explicit_match,
+        }
+    }
+}
+
+// ── Policy Denial ────────────────────────────────────────────────────────────
+
+/// A single denial reason from the policy engine.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Denial {
+    /// The ref that was denied.
+    pub ref_name: String,
+    /// Human-readable reason.
+    pub reason: String,
+}
+
+impl fmt::Display for Denial {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}: {}", self.ref_name, self.reason)
+    }
+}
+
+/// Evaluate a single ref update against effective rules and the pusher's role.
+///
+/// Returns `Ok(())` if allowed, `Err(Denial)` if denied.
+pub fn evaluate_ref_update(
+    update: &RefUpdate,
+    role: MemberRole,
+    rules: &[ProtectionRule],
+) -> Result<(), Denial> {
+    let effective = EffectiveRules::for_ref(&update.ref_name, rules);
+
+    // If no explicit rules match, use built-in defaults.
+    if !effective.has_explicit_match {
+        let min_role = default_min_role(&update.ref_name, update.kind);
+        if !role.has_at_least(min_role) {
+            return Err(Denial {
+                ref_name: update.ref_name.clone(),
+                reason: format!(
+                    "requires {} role (you have {}), using built-in defaults",
+                    min_role, role
+                ),
+            });
+        }
+        return Ok(());
+    }
+
+    // Check require-patch (blocks all direct pushes).
+    if effective.require_patch {
+        return Err(Denial {
+            ref_name: update.ref_name.clone(),
+            reason: "direct push denied: require-patch is set, submit a NIP-34 patch".to_string(),
+        });
+    }
+
+    // Check push role.
+    // Explicit push:role can NEVER weaken the built-in default. Always take the
+    // HIGHER of (explicit, default). This prevents `push:member` from accidentally
+    // allowing Members to force-push, delete, or overwrite tags.
+    let default_role = default_min_role(&update.ref_name, update.kind);
+    let min_role = match effective.push_role {
+        Some(explicit) => {
+            // Take the stricter (higher permission level) of explicit vs default.
+            if explicit.permission_level() >= default_role.permission_level() {
+                explicit
+            } else {
+                default_role
+            }
+        }
+        None => default_role,
+    };
+    if !role.has_at_least(min_role) {
+        return Err(Denial {
+            ref_name: update.ref_name.clone(),
+            reason: format!("requires {} role (you have {})", min_role, role),
+        });
+    }
+
+    // Check no-force-push.
+    if effective.no_force_push && update.kind == UpdateKind::NonFastForward {
+        return Err(Denial {
+            ref_name: update.ref_name.clone(),
+            reason: "non-fast-forward update denied: no-force-push is set".to_string(),
+        });
+    }
+
+    // Check no-delete.
+    if effective.no_delete && update.kind == UpdateKind::Delete {
+        return Err(Denial {
+            ref_name: update.ref_name.clone(),
+            reason: "ref deletion denied: no-delete is set".to_string(),
+        });
+    }
+
+    Ok(())
+}
+
+/// Evaluate an entire push (multiple ref updates) against protection rules.
+///
+/// Returns `Ok(())` if ALL refs are allowed, `Err(Vec<Denial>)` if any are denied.
+/// A push is atomic — if any ref fails, the entire push is rejected.
+pub fn evaluate_push(
+    updates: &[RefUpdate],
+    role: MemberRole,
+    rules: &[ProtectionRule],
+) -> Result<(), Vec<Denial>> {
+    let denials: Vec<Denial> = updates
+        .iter()
+        .filter_map(|update| evaluate_ref_update(update, role, rules).err())
+        .collect();
+
+    if denials.is_empty() {
+        Ok(())
+    } else {
+        Err(denials)
+    }
+}
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // ── RefPattern tests ─────────────────────────────────────────────────
+
+    #[test]
+    fn pattern_parse_valid() {
+        let p = RefPattern::parse("refs/heads/main").unwrap();
+        assert_eq!(p.segments.len(), 3);
+        assert!(p.matches("refs/heads/main"));
+        assert!(!p.matches("refs/heads/develop"));
+    }
+
+    #[test]
+    fn pattern_wildcard_matches_one_segment() {
+        let p = RefPattern::parse("refs/heads/*").unwrap();
+        assert!(p.matches("refs/heads/main"));
+        assert!(p.matches("refs/heads/feature"));
+        assert!(!p.matches("refs/heads/feature/sub"));
+        assert!(!p.matches("refs/tags/v1"));
+    }
+
+    #[test]
+    fn pattern_multi_wildcard() {
+        let p = RefPattern::parse("refs/*/release/*").unwrap();
+        assert!(p.matches("refs/heads/release/v1"));
+        assert!(!p.matches("refs/heads/release/v1/hotfix"));
+    }
+
+    #[test]
+    fn pattern_rejects_partial_glob() {
+        assert!(matches!(
+            RefPattern::parse("refs/tags/v*"),
+            Err(PatternError::InvalidSegment(_))
+        ));
+    }
+
+    #[test]
+    fn pattern_rejects_missing_refs_prefix() {
+        assert!(matches!(
+            RefPattern::parse("heads/main"),
+            Err(PatternError::MissingRefsPrefix)
+        ));
+    }
+
+    #[test]
+    fn pattern_rejects_empty() {
+        assert!(matches!(RefPattern::parse(""), Err(PatternError::Empty)));
+    }
+
+    #[test]
+    fn pattern_rejects_too_many_wildcards() {
+        assert!(matches!(
+            RefPattern::parse("refs/*/*/*/*"),
+            Err(PatternError::TooManyWildcards)
+        ));
+    }
+
+    #[test]
+    fn pattern_recursive_wildcard_matches_nested() {
+        let p = RefPattern::parse("refs/heads/**").unwrap();
+        assert!(p.matches("refs/heads/main"));
+        assert!(p.matches("refs/heads/feature/foo"));
+        assert!(p.matches("refs/heads/feature/foo/bar"));
+        assert!(!p.matches("refs/tags/v1"));
+    }
+
+    #[test]
+    fn pattern_recursive_wildcard_must_be_last() {
+        assert!(matches!(
+            RefPattern::parse("refs/**/heads"),
+            Err(PatternError::InvalidSegment(_))
+        ));
+    }
+
+    #[test]
+    fn pattern_recursive_requires_at_least_one_segment() {
+        let p = RefPattern::parse("refs/heads/**").unwrap();
+        // Must match at least one segment after prefix
+        assert!(!p.matches("refs/heads"));
+    }
+
+    // ── UpdateKind tests ─────────────────────────────────────────────────
+
+    #[test]
+    fn classify_create() {
+        let zero = "0000000000000000000000000000000000000000";
+        assert_eq!(
+            UpdateKind::classify(zero, "abc123abc123abc123abc123abc123abc123abcd", false),
+            UpdateKind::Create
+        );
+    }
+
+    #[test]
+    fn classify_delete() {
+        let zero = "0000000000000000000000000000000000000000";
+        assert_eq!(
+            UpdateKind::classify("abc123abc123abc123abc123abc123abc123abcd", zero, false),
+            UpdateKind::Delete
+        );
+    }
+
+    #[test]
+    fn classify_fast_forward() {
+        assert_eq!(
+            UpdateKind::classify("aaa", "bbb", true),
+            UpdateKind::FastForward
+        );
+    }
+
+    #[test]
+    fn classify_non_fast_forward() {
+        assert_eq!(
+            UpdateKind::classify("aaa", "bbb", false),
+            UpdateKind::NonFastForward
+        );
+    }
+
+    // ── Protection rule parsing ──────────────────────────────────────────
+
+    #[test]
+    fn parse_protection_tag_basic() {
+        let rule =
+            parse_protection_tag(&["refs/heads/main", "push:admin", "no-force-push"]).unwrap();
+        assert_eq!(rule.push_role, Some(MemberRole::Admin));
+        assert!(rule.no_force_push);
+        assert!(!rule.no_delete);
+        assert!(!rule.require_patch);
+    }
+
+    #[test]
+    fn parse_protection_tag_all_rules() {
+        let rule = parse_protection_tag(&[
+            "refs/heads/main",
+            "push:owner",
+            "no-force-push",
+            "no-delete",
+            "require-patch",
+        ])
+        .unwrap();
+        assert_eq!(rule.push_role, Some(MemberRole::Owner));
+        assert!(rule.no_force_push);
+        assert!(rule.no_delete);
+        assert!(rule.require_patch);
+    }
+
+    #[test]
+    fn parse_protection_tag_unknown_rule_skipped() {
+        // Forward-compatibility: unknown rules are silently skipped.
+        let rule = parse_protection_tag(&["refs/heads/main", "yolo", "no-force-push"]).unwrap();
+        // "yolo" was skipped, but "no-force-push" was still applied.
+        assert!(rule.no_force_push);
+        assert!(rule.push_role.is_none());
+    }
+
+    #[test]
+    fn parse_protection_tag_invalid_role() {
+        assert!(matches!(
+            parse_protection_tag(&["refs/heads/main", "push:superadmin"]),
+            Err(RuleParseError::InvalidRole(_))
+        ));
+    }
+
+    #[test]
+    fn parse_protection_tag_rejects_push_bot_and_guest() {
+        // push:bot and push:guest are rejected — they're almost certainly user errors.
+        assert!(matches!(
+            parse_protection_tag(&["refs/heads/main", "push:bot"]),
+            Err(RuleParseError::InvalidRole(_))
+        ));
+        assert!(matches!(
+            parse_protection_tag(&["refs/heads/main", "push:guest"]),
+            Err(RuleParseError::InvalidRole(_))
+        ));
+    }
+
+    // ── Effective rules (union semantics) ────────────────────────────────
+
+    #[test]
+    fn effective_rules_union_strictest_role() {
+        let rules = vec![
+            parse_protection_tag(&["refs/heads/*", "push:member", "no-force-push"]).unwrap(),
+            parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap(),
+        ];
+        let eff = EffectiveRules::for_ref("refs/heads/main", &rules);
+        assert_eq!(eff.push_role, Some(MemberRole::Admin)); // strictest
+        assert!(eff.no_force_push); // from the wildcard rule
+        assert!(eff.has_explicit_match);
+    }
+
+    #[test]
+    fn effective_rules_no_match_uses_defaults() {
+        let rules = vec![parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap()];
+        let eff = EffectiveRules::for_ref("refs/heads/develop", &rules);
+        assert!(!eff.has_explicit_match);
+    }
+
+    // ── Policy evaluation ────────────────────────────────────────────────
+
+    #[test]
+    fn evaluate_owner_passes_push_role() {
+        let rules = vec![parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap()];
+        let update = RefUpdate {
+            ref_name: "refs/heads/main".to_string(),
+            kind: UpdateKind::FastForward,
+            old_oid: "a".repeat(40),
+            new_oid: "b".repeat(40),
+        };
+        assert!(evaluate_ref_update(&update, MemberRole::Owner, &rules).is_ok());
+    }
+
+    #[test]
+    fn evaluate_member_denied_push_admin() {
+        let rules = vec![parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap()];
+        let update = RefUpdate {
+            ref_name: "refs/heads/main".to_string(),
+            kind: UpdateKind::FastForward,
+            old_oid: "a".repeat(40),
+            new_oid: "b".repeat(40),
+        };
+        assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_err());
+    }
+
+    #[test]
+    fn evaluate_no_force_push_blocks_owner() {
+        let rules =
+            vec![
+                parse_protection_tag(&["refs/heads/main", "push:member", "no-force-push"]).unwrap(),
+            ];
+        let update = RefUpdate {
+            ref_name: "refs/heads/main".to_string(),
+            kind: UpdateKind::NonFastForward,
+            old_oid: "a".repeat(40),
+            new_oid: "b".repeat(40),
+        };
+        // Owner is blocked by no-force-push!
+        assert!(evaluate_ref_update(&update, MemberRole::Owner, &rules).is_err());
+    }
+
+    #[test]
+    fn evaluate_no_force_push_allows_fast_forward() {
+        let rules =
+            vec![
+                parse_protection_tag(&["refs/heads/main", "push:member", "no-force-push"]).unwrap(),
+            ];
+        let update = RefUpdate {
+            ref_name: "refs/heads/main".to_string(),
+            kind: UpdateKind::FastForward,
+            old_oid: "a".repeat(40),
+            new_oid: "b".repeat(40),
+        };
+        // no-force-push should NOT block fast-forward pushes.
+        assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_ok());
+    }
+
+    #[test]
+    fn evaluate_no_delete_blocks_admin() {
+        let rules =
+            vec![parse_protection_tag(&["refs/heads/main", "push:member", "no-delete"]).unwrap()];
+        let update = RefUpdate {
+            ref_name: "refs/heads/main".to_string(),
+            kind: UpdateKind::Delete,
+            old_oid: "a".repeat(40),
+            new_oid: "0".repeat(40),
+        };
+        assert!(evaluate_ref_update(&update, MemberRole::Admin, &rules).is_err());
+    }
+
+    #[test]
+    fn evaluate_require_patch_blocks_all() {
+        let rules = vec![parse_protection_tag(&["refs/heads/main", "require-patch"]).unwrap()];
+        let update = RefUpdate {
+            ref_name: "refs/heads/main".to_string(),
+            kind: UpdateKind::FastForward,
+            old_oid: "a".repeat(40),
+            new_oid: "b".repeat(40),
+        };
+        assert!(evaluate_ref_update(&update, MemberRole::Owner, &rules).is_err());
+    }
+
+    #[test]
+    fn evaluate_defaults_member_can_ff_branch() {
+        let rules = vec![]; // No explicit rules
+        let update = RefUpdate {
+            ref_name: "refs/heads/feature".to_string(),
+            kind: UpdateKind::FastForward,
+            old_oid: "a".repeat(40),
+            new_oid: "b".repeat(40),
+        };
+        assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_ok());
+    }
+
+    #[test]
+    fn evaluate_defaults_member_cannot_force_push() {
+        let rules = vec![]; // No explicit rules — defaults apply
+        let update = RefUpdate {
+            ref_name: "refs/heads/feature".to_string(),
+            kind: UpdateKind::NonFastForward,
+            old_oid: "a".repeat(40),
+            new_oid: "b".repeat(40),
+        };
+        // Default: non-fast-forward requires Admin
+        assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_err());
+    }
+
+    #[test]
+    fn evaluate_defaults_guest_cannot_push() {
+        let rules = vec![];
+        let update = RefUpdate {
+            ref_name: "refs/heads/feature".to_string(),
+            kind: UpdateKind::FastForward,
+            old_oid: "a".repeat(40),
+            new_oid: "b".repeat(40),
+        };
+        assert!(evaluate_ref_update(&update, MemberRole::Guest, &rules).is_err());
+    }
+
+    #[test]
+    fn evaluate_bot_cannot_push_without_explicit_grant() {
+        let rules = vec![];
+        let update = RefUpdate {
+            ref_name: "refs/heads/feature".to_string(),
+            kind: UpdateKind::FastForward,
+            old_oid: "a".repeat(40),
+            new_oid: "b".repeat(40),
+        };
+        // Bot has permission_level 0 at the core evaluator level.
+        // NOTE: The policy layer (policy.rs) promotes Bot → Member before calling
+        // evaluate_push, so bots in a channel CAN push in practice. This test
+        // verifies the raw evaluator behavior; the promotion is tested in policy.
+        assert!(evaluate_ref_update(&update, MemberRole::Bot, &rules).is_err());
+    }
+
+    #[test]
+    fn evaluate_guest_denied_even_with_only_no_force_push_rule() {
+        // Regression test: a rule that only sets no-force-push (no push:role)
+        // should NOT let a Guest bypass the built-in default (Member required).
+        let rules = vec![parse_protection_tag(&["refs/heads/main", "no-force-push"]).unwrap()];
+        let update = RefUpdate {
+            ref_name: "refs/heads/main".to_string(),
+            kind: UpdateKind::FastForward,
+            old_oid: "a".repeat(40),
+            new_oid: "b".repeat(40),
+        };
+        // Guest should be denied — built-in default requires Member for FF push.
+        assert!(evaluate_ref_update(&update, MemberRole::Guest, &rules).is_err());
+        // Member should be allowed (meets default requirement).
+        assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_ok());
+    }
+
+    #[test]
+    fn evaluate_push_member_cannot_weaken_destructive_defaults() {
+        // push:member should NOT allow Members to force-push or delete.
+        // The built-in default for NFF/Delete is Admin — explicit push:member
+        // can't weaken that for destructive operations.
+        let rules = vec![parse_protection_tag(&["refs/heads/main", "push:member"]).unwrap()];
+        // Member can FF push (non-destructive, explicit overrides default).
+        let ff_update = RefUpdate {
+            ref_name: "refs/heads/main".to_string(),
+            kind: UpdateKind::FastForward,
+            old_oid: "a".repeat(40),
+            new_oid: "b".repeat(40),
+        };
+        assert!(evaluate_ref_update(&ff_update, MemberRole::Member, &rules).is_ok());
+
+        // Member CANNOT force-push (destructive, default Admin still enforced).
+        let nff_update = RefUpdate {
+            ref_name: "refs/heads/main".to_string(),
+            kind: UpdateKind::NonFastForward,
+            old_oid: "a".repeat(40),
+            new_oid: "b".repeat(40),
+        };
+        assert!(evaluate_ref_update(&nff_update, MemberRole::Member, &rules).is_err());
+
+        // Admin CAN force-push (meets the default Admin requirement).
+        assert!(evaluate_ref_update(&nff_update, MemberRole::Admin, &rules).is_ok());
+
+        // Member CANNOT delete (destructive, default Admin still enforced).
+        let del_update = RefUpdate {
+            ref_name: "refs/heads/main".to_string(),
+            kind: UpdateKind::Delete,
+            old_oid: "a".repeat(40),
+            new_oid: "0".repeat(40),
+        };
+        assert!(evaluate_ref_update(&del_update, MemberRole::Member, &rules).is_err());
+    }
+
+    #[test]
+    fn evaluate_push_multiple_refs_partial_deny() {
+        let rules = vec![parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap()];
+        let updates = vec![
+            RefUpdate {
+                ref_name: "refs/heads/feature".to_string(),
+                kind: UpdateKind::FastForward,
+                old_oid: "a".repeat(40),
+                new_oid: "b".repeat(40),
+            },
+            RefUpdate {
+                ref_name: "refs/heads/main".to_string(),
+                kind: UpdateKind::FastForward,
+                old_oid: "c".repeat(40),
+                new_oid: "d".repeat(40),
+            },
+        ];
+        // Member can push to feature but not main
+        let result = evaluate_push(&updates, MemberRole::Member, &rules);
+        assert!(result.is_err());
+        let denials = result.unwrap_err();
+        assert_eq!(denials.len(), 1);
+        assert_eq!(denials[0].ref_name, "refs/heads/main");
+    }
+}
diff --git a/crates/sprout-core/src/lib.rs b/crates/sprout-core/src/lib.rs
index fe9f5dfd2..d3eeecb72 100644
--- a/crates/sprout-core/src/lib.rs
+++ b/crates/sprout-core/src/lib.rs
@@ -13,6 +13,8 @@ pub mod error;
 pub mod event;
 /// NIP-01 subscription filter matching.
 pub mod filter;
+/// Git permission types — ref patterns, protection rules, policy evaluation.
+pub mod git_perms;
 /// Sprout kind number registry — custom event type constants.
 pub mod kind;
 /// Network utilities — SSRF-safe IP classification.
diff --git a/crates/sprout-relay/Cargo.toml b/crates/sprout-relay/Cargo.toml
index ab56a601c..4c3cdaa2e 100644
--- a/crates/sprout-relay/Cargo.toml
+++ b/crates/sprout-relay/Cargo.toml
@@ -44,6 +44,9 @@ bytes = "1"
 infer = "0.19"
 serde_yaml      = { workspace = true }
 sha2            = { workspace = true }
+hmac            = { workspace = true }
+subtle          = { workspace = true }
+rand            = { workspace = true }
 hex             = { workspace = true }
 url             = { workspace = true }
 moka            = { workspace = true }
diff --git a/crates/sprout-relay/src/api/git/hook.rs b/crates/sprout-relay/src/api/git/hook.rs
new file mode 100644
index 000000000..2bc1133e6
--- /dev/null
+++ b/crates/sprout-relay/src/api/git/hook.rs
@@ -0,0 +1,176 @@
+//! Pre-receive hook script generation and injection.
+//!
+//! The hook is a shell script that:
+//! 1. Reads `old_oid new_oid ref_name` lines from stdin
+//! 2. For each non-create/non-delete, runs `git merge-base --is-ancestor`
+//!    (inheriting quarantine env vars)
+//! 3. POSTs the payload to the relay's internal policy endpoint with HMAC
+//! 4. Exits non-zero on ANY non-200 response (fail-closed)
+//!
+//! Security invariants:
+//! - Fail-closed: curl failure, timeout, non-200 → exit 1
+//! - Quarantine vars inherited for ancestry checks
+//! - HMAC binds callback to specific push operation
+
+use std::path::Path;
+
+use tokio::fs;
+use tracing::{error, info};
+
+/// The pre-receive hook script content.
+///
+/// Environment variables set by the relay before spawning git receive-pack:
+/// - `SPROUT_HOOK_URL` — internal policy endpoint (http://127.0.0.1:{port}/internal/git/policy)
+/// - `SPROUT_HOOK_SECRET` — per-push HMAC secret
+/// - `SPROUT_REPO_ID` — repo identifier (d-tag)
+/// - `SPROUT_PUSHER_PUBKEY` — authenticated pusher's hex pubkey
+///
+/// Git sets automatically (quarantine):
+/// - `GIT_OBJECT_DIRECTORY` — quarantine object store
+/// - `GIT_ALTERNATE_OBJECT_DIRECTORIES` — includes the real object store
+const PRE_RECEIVE_HOOK: &str = r#"#!/usr/bin/env bash
+# Sprout pre-receive hook — FAIL-CLOSED
+# ANY error, timeout, or non-200 response → reject the push.
+set -eo pipefail
+
+# Force C locale for deterministic sort order and byte-accurate string lengths.
+# Rust uses byte-order comparison and byte lengths — locale-aware sort/strlen would mismatch.
+export LC_ALL=C
+
+ZERO="0000000000000000000000000000000000000000"
+
+# Fail-closed: required env vars must be set by the relay.
+: "${SPROUT_REPO_ID:?error: SPROUT_REPO_ID not set}"
+: "${SPROUT_REPO_OWNER:?error: SPROUT_REPO_OWNER not set}"
+: "${SPROUT_PUSHER_PUBKEY:?error: SPROUT_PUSHER_PUBKEY not set}"
+: "${SPROUT_HOOK_URL:?error: SPROUT_HOOK_URL not set}"
+: "${SPROUT_HOOK_SECRET:?error: SPROUT_HOOK_SECRET not set}"
+
+WORK_DIR=$(mktemp -d) || { echo "error: cannot create temp dir" >&2; exit 1; }
+REFS_FILE="$WORK_DIR/refs"
+HMAC_FILE="$WORK_DIR/hmac"
+RESP_FILE="$WORK_DIR/resp"
+trap 'rm -rf "$WORK_DIR"' EXIT
+
+# Phase 1: Read ref updates from stdin, classify each, build JSON + HMAC lines.
+# We write two files in parallel:
+#   REFS_FILE: JSON entries (unsorted, for the request body)
+#   HMAC_FILE: "ref_name old_oid new_oid" lines (for sorting → HMAC input)
+REFS=""
+while read -r old_oid new_oid ref_name; do
+    # Ancestry check for FF detection.
+    # CRITICAL: GIT_OBJECT_DIRECTORY and GIT_ALTERNATE_OBJECT_DIRECTORIES are
+    # inherited from our environment (git sets them for quarantine). Any git
+    # subprocess we call sees the quarantined objects automatically.
+    IS_ANCESTOR="false"
+    if [ "$old_oid" != "$ZERO" ] && [ "$new_oid" != "$ZERO" ]; then
+        # Exit 0 = is ancestor (FF), exit 1 = not ancestor (NFF),
+        # exit 128 = error → treat as NFF (fail-closed).
+        if git merge-base --is-ancestor "$old_oid" "$new_oid" 2>/dev/null; then
+            IS_ANCESTOR="true"
+        fi
+    fi
+
+    # JSON entry for request body.
+    # Escape any special JSON characters in ref_name (defense against injection).
+    # Git ref names can't contain most special chars, but belt-and-suspenders.
+    SAFE_REF=$(printf '%s' "$ref_name" | sed 's/\\/\\\\/g; s/"/\\"/g')
+
+    if [ -n "$REFS" ]; then
+        REFS="${REFS},"
+    fi
+    REFS="${REFS}{\"old_oid\":\"${old_oid}\",\"new_oid\":\"${new_oid}\",\"ref_name\":\"${SAFE_REF}\",\"is_ancestor\":${IS_ANCESTOR}}"
+
+    # HMAC line: ref_name first (for sorting), then oids + is_ancestor.
+    # is_ancestor as "1" or "0" to match Rust's b"1"/b"0".
+    if [ "$IS_ANCESTOR" = "true" ]; then
+        echo "${ref_name} ${old_oid} ${new_oid} 1" >> "$HMAC_FILE"
+    else
+        echo "${ref_name} ${old_oid} ${new_oid} 0" >> "$HMAC_FILE"
+    fi
+done
+
+# Phase 2: Compute HMAC-SHA256 signature.
+# Payload format MUST match relay's compute_hmac() in policy.rs:
+#   repo_id | repo_owner | pusher_pubkey | (old_oid + new_oid + ref_name + is_ancestor) per ref sorted by ref_name | timestamp
+TIMESTAMP=$(date +%s)
+
+# Structurally unambiguous HMAC format (matches Rust's compute_hmac):
+# len(repo_id):repo_id | repo_owner | pusher | (old_oid + new_oid + len(ref):ref + is_anc)* | timestamp
+REPO_ID_LEN=${#SPROUT_REPO_ID}
+HMAC_INPUT="${REPO_ID_LEN}:${SPROUT_REPO_ID}|${SPROUT_REPO_OWNER}|${SPROUT_PUSHER_PUBKEY}|"
+# Sort by ref_name (field 1) — matches Rust's sort_by(|a, b| a.ref_name.cmp(&b.ref_name))
+if [ -f "$HMAC_FILE" ]; then
+    sort "$HMAC_FILE" | while IFS=' ' read ref_name old_oid new_oid is_anc; do
+        REF_LEN=${#ref_name}
+        printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc"
+    done > "$HMAC_FILE.concat"
+    HMAC_INPUT="${HMAC_INPUT}$(cat "$HMAC_FILE.concat")"
+    rm -f "$HMAC_FILE.concat"
+fi
+HMAC_INPUT="${HMAC_INPUT}|${TIMESTAMP}"
+
+SIGNATURE=$(printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "$SPROUT_HOOK_SECRET" -hex 2>/dev/null | sed 's/.*= //')
+if [ -z "$SIGNATURE" ]; then
+    echo "error: failed to compute HMAC signature" >&2
+    exit 1
+fi
+
+# Phase 3: POST to policy endpoint — FAIL-CLOSED.
+# repo_id is free-form (user-chosen d-tag) — must be escaped for JSON safety.
+# repo_owner and pusher_pubkey are validated 64-char lowercase hex — no escaping needed.
+SAFE_REPO_ID=$(printf '%s' "$SPROUT_REPO_ID" | sed 's/\\/\\\\/g; s/"/\\"/g')
+BODY="{\"repo_id\":\"${SAFE_REPO_ID}\",\"repo_owner\":\"${SPROUT_REPO_OWNER}\",\"pusher_pubkey\":\"${SPROUT_PUSHER_PUBKEY}\",\"ref_updates\":[${REFS}],\"timestamp\":${TIMESTAMP},\"signature\":\"${SIGNATURE}\"}"
+
+HTTP_CODE=$(curl --silent --max-time 10 \
+    -o "$RESP_FILE" \
+    -w "%{http_code}" \
+    -X POST \
+    -H "Content-Type: application/json" \
+    -d "$BODY" \
+    "$SPROUT_HOOK_URL" 2>/dev/null) || {
+    echo "error: push authorization failed (network error reaching policy service)" >&2
+    exit 1
+}
+
+if [ "$HTTP_CODE" != "200" ]; then
+    echo "error: push denied by policy (HTTP $HTTP_CODE)" >&2
+    cat "$RESP_FILE" >&2 2>/dev/null
+    exit 1
+fi
+
+exit 0
+"#;
+
+/// Install the pre-receive hook into a bare repository.
+///
+/// Creates a `hooks/` directory and writes the hook script with execute permission.
+/// Called during repo creation (kind:30617 handling) and can be called to
+/// retrofit existing repos.
+pub async fn install_hook(repo_path: &Path) -> anyhow::Result<()> {
+    let hooks_dir = repo_path.join("hooks");
+    fs::create_dir_all(&hooks_dir).await.map_err(|e| {
+        error!(path = %hooks_dir.display(), error = %e, "failed to create hooks dir");
+        anyhow::anyhow!("failed to create hooks directory: {e}")
+    })?;
+
+    let hook_path = hooks_dir.join("pre-receive");
+    fs::write(&hook_path, PRE_RECEIVE_HOOK).await.map_err(|e| {
+        error!(path = %hook_path.display(), error = %e, "failed to write hook");
+        anyhow::anyhow!("failed to write pre-receive hook: {e}")
+    })?;
+
+    // Make executable (Unix only).
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::PermissionsExt;
+        let perms = std::fs::Permissions::from_mode(0o755);
+        std::fs::set_permissions(&hook_path, perms).map_err(|e| {
+            error!(path = %hook_path.display(), error = %e, "failed to chmod hook");
+            anyhow::anyhow!("failed to set hook permissions: {e}")
+        })?;
+    }
+
+    info!(repo = %repo_path.display(), "pre-receive hook installed");
+    Ok(())
+}
diff --git a/crates/sprout-relay/src/api/git/mod.rs b/crates/sprout-relay/src/api/git/mod.rs
new file mode 100644
index 000000000..538122bc4
--- /dev/null
+++ b/crates/sprout-relay/src/api/git/mod.rs
@@ -0,0 +1,60 @@
+//! Git hosting — Smart HTTP transport, permission hooks, and policy engine.
+//!
+//! # Module structure
+//!
+//! - `transport` — Smart HTTP protocol (info/refs, upload-pack, receive-pack)
+//! - `hook` — Pre-receive hook script and injection
+//! - `policy` — Internal policy endpoint (HMAC-authenticated callback from hook)
+
+use std::net::SocketAddr;
+use std::sync::Arc;
+
+use axum::{
+    body::Body,
+    extract::ConnectInfo,
+    http::{Request, StatusCode},
+    middleware::{self, Next},
+    response::{IntoResponse, Response},
+    routing::post,
+    Router,
+};
+use tower_http::limit::RequestBodyLimitLayer;
+
+use crate::state::AppState;
+
+pub mod hook;
+pub mod policy;
+pub mod transport;
+
+pub use transport::git_router;
+
+/// Middleware that rejects requests from non-loopback addresses.
+///
+/// Defense-in-depth: the internal policy endpoint should only be reachable
+/// from localhost (the pre-receive hook runs on the same host as the relay).
+async fn require_localhost(req: Request<Body>, next: Next) -> Response {
+    let is_loopback = req
+        .extensions()
+        .get::<ConnectInfo<SocketAddr>>()
+        .map(|ci| ci.0.ip().is_loopback())
+        .unwrap_or(false);
+
+    if !is_loopback {
+        return (StatusCode::FORBIDDEN, "internal endpoint: localhost only").into_response();
+    }
+
+    next.run(req).await
+}
+
+/// Build the internal git policy router.
+///
+/// Mounted at `/internal/git/policy` — only accessible from localhost.
+/// The pre-receive hook calls this to authorize pushes.
+/// Body limit: 1 MB (500 refs × ~200 bytes each = ~100 KB typical; 1 MB is generous).
+pub fn git_policy_router(state: Arc<AppState>) -> Router {
+    Router::new()
+        .route("/internal/git/policy", post(policy::hook_policy_check))
+        .layer(RequestBodyLimitLayer::new(1024 * 1024)) // 1 MB
+        .layer(middleware::from_fn(require_localhost))
+        .with_state(state)
+}
diff --git a/crates/sprout-relay/src/api/git/policy.rs b/crates/sprout-relay/src/api/git/policy.rs
new file mode 100644
index 000000000..6fc0a1a72
--- /dev/null
+++ b/crates/sprout-relay/src/api/git/policy.rs
@@ -0,0 +1,726 @@
+//! Internal policy endpoint — pre-receive hook callback.
+//!
+//! The pre-receive hook POSTs here with HMAC-signed payload containing
+//! the pusher's pubkey, repo ID, and ref updates. This endpoint:
+//!
+//! 1. Validates HMAC signature + 30s TTL (fail-closed)
+//! 2. Resolves kind:30617 → protection rules
+//! 3. Resolves pusher's channel role via sprout-channel binding
+//! 4. Promotes Bot → Member (bots in a channel push as members)
+//! 5. Calls `sprout_core::git_perms::evaluate_push()`
+//! 6. Returns 200 (allow) or 403 (deny with reasons)
+//!
+//! # Bot Role Model
+//!
+//! Bots are intentionally added to channels by members/admins. For git push,
+//! they're promoted to Member — protection rules still apply. Bot is a
+//! designation (what it is), not a permission tier (what it can do). The
+//! promotion is scoped to this module; the core `MemberRole::Bot` hierarchy
+//! is unchanged.
+//!
+//! # Security invariants
+//!
+//! - Endpoint binds to 127.0.0.1 only (enforced at router level)
+//! - HMAC binds callback to the specific push operation
+//! - Fail-closed: any error → 403
+
+use std::sync::Arc;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use axum::{
+    extract::State,
+    http::StatusCode,
+    response::{IntoResponse, Response},
+    Json,
+};
+use hmac::{Hmac, KeyInit, Mac};
+use serde::{Deserialize, Serialize};
+use sha2::Sha256;
+use tracing::{error, warn};
+
+use uuid::Uuid;
+
+use sprout_core::channel::MemberRole;
+use sprout_core::git_perms::{evaluate_push, parse_protection_tags, Denial, RefUpdate, UpdateKind};
+use sprout_db::EventQuery;
+
+use crate::state::AppState;
+
+// ── Types ────────────────────────────────────────────────────────────────────
+
+/// Maximum age of a hook callback (seconds). Push is synchronous so 30s is generous.
+const MAX_CALLBACK_AGE_SECS: u64 = 30;
+
+/// Request payload from the pre-receive hook.
+#[derive(Debug, Clone, Deserialize)]
+pub struct HookCallbackRequest {
+    /// Repo identifier (d-tag from kind:30617).
+    pub repo_id: String,
+    /// Hex-encoded repo owner pubkey (from URL path, verified against kind:30617).
+    pub repo_owner: String,
+    /// Hex-encoded pusher pubkey.
+    pub pusher_pubkey: String,
+    /// Ref updates from git stdin (old_oid, new_oid, ref_name, is_ancestor).
+    pub ref_updates: Vec<HookRefUpdate>,
+    /// Unix timestamp when the hook was invoked.
+    pub timestamp: u64,
+    /// HMAC-SHA256 signature over the canonical payload.
+    pub signature: String,
+}
+
+/// A single ref update as reported by the pre-receive hook.
+#[derive(Debug, Clone, Deserialize)]
+pub struct HookRefUpdate {
+    /// Old object ID (40 hex chars, zero OID for creates).
+    pub old_oid: String,
+    /// New object ID (40 hex chars, zero OID for deletes).
+    pub new_oid: String,
+    /// Full ref name (e.g., "refs/heads/main").
+    pub ref_name: String,
+    /// Result of `git merge-base --is-ancestor old new`.
+    /// For creates/deletes this is false (ignored by classifier).
+    pub is_ancestor: bool,
+}
+
+/// Response to the hook — either allow or deny.
+#[derive(Debug, Serialize)]
+pub struct HookCallbackResponse {
+    /// Whether the push is allowed.
+    pub allowed: bool,
+    /// Denial reasons (empty if allowed).
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    pub denials: Vec<DenialResponse>,
+}
+
+/// A single denial reason in the hook response.
+#[derive(Debug, Serialize)]
+pub struct DenialResponse {
+    /// The ref that was denied.
+    pub ref_name: String,
+    /// Human-readable reason for denial.
+    pub reason: String,
+}
+
+impl From<Denial> for DenialResponse {
+    fn from(d: Denial) -> Self {
+        Self {
+            ref_name: d.ref_name,
+            reason: d.reason,
+        }
+    }
+}
+
+// ── HMAC Verification ────────────────────────────────────────────────────────
+
+/// Compute the canonical HMAC payload.
+///
+/// Format (length-prefixed, `|`-separated, structurally unambiguous):
+/// ```text
+/// len(repo_id):repo_id | repo_owner(64) | pusher(64) | sorted_refs | timestamp
+/// ```
+/// where each ref is: `old_oid(40) + new_oid(40) + len(ref_name):ref_name + is_ancestor("1"/"0")`
+///
+/// Fixed-length fields (OIDs=40, pubkeys=64) need no length prefix.
+/// Variable-length fields (repo_id, ref_name) are length-prefixed to prevent concatenation ambiguity.
+fn compute_hmac(secret: &[u8], req: &HookCallbackRequest) -> Vec<u8> {
+    let mut mac = Hmac::<Sha256>::new_from_slice(secret).expect("HMAC can take key of any size");
+
+    // Structurally unambiguous format: length-prefixed fields separated by |.
+    // This prevents field confusion attacks (e.g., repo_id="a|b" being parsed differently).
+    mac.update(req.repo_id.len().to_string().as_bytes());
+    mac.update(b":");
+    mac.update(req.repo_id.as_bytes());
+    mac.update(b"|");
+    mac.update(req.repo_owner.as_bytes()); // Fixed 64 chars, no ambiguity.
+    mac.update(b"|");
+    mac.update(req.pusher_pubkey.as_bytes()); // Fixed 64 chars, no ambiguity.
+    mac.update(b"|");
+    // Deterministic ref update representation: sorted by ref_name.
+    // Each ref is length-prefixed to prevent concatenation ambiguity.
+    let mut refs_sorted: Vec<&HookRefUpdate> = req.ref_updates.iter().collect();
+    refs_sorted.sort_by(|a, b| a.ref_name.cmp(&b.ref_name));
+    for r in &refs_sorted {
+        mac.update(r.old_oid.as_bytes()); // Fixed 40 chars.
+        mac.update(r.new_oid.as_bytes()); // Fixed 40 chars.
+        mac.update(r.ref_name.len().to_string().as_bytes());
+        mac.update(b":");
+        mac.update(r.ref_name.as_bytes());
+        mac.update(if r.is_ancestor { b"1" } else { b"0" });
+    }
+    mac.update(b"|");
+    mac.update(req.timestamp.to_string().as_bytes());
+
+    mac.finalize().into_bytes().to_vec()
+}
+
+/// Verify the HMAC signature on a hook callback.
+fn verify_hmac(secret: &[u8], req: &HookCallbackRequest) -> bool {
+    let expected = compute_hmac(secret, req);
+    let provided = match hex::decode(&req.signature) {
+        Ok(bytes) => bytes,
+        Err(_) => return false,
+    };
+    // Constant-time comparison.
+    use subtle::ConstantTimeEq;
+    expected.ct_eq(&provided).into()
+}
+
+// ── Handler ──────────────────────────────────────────────────────────────────
+
+/// `POST /internal/git/policy` — pre-receive hook callback.
+///
+/// Fail-closed: ANY error returns 403. The hook script treats non-200 as deny.
+pub async fn hook_policy_check(
+    State(state): State<Arc<AppState>>,
+    Json(req): Json<HookCallbackRequest>,
+) -> Response {
+    // 1. Validate input fields (cheap structural checks before expensive HMAC).
+    // This prevents wasting CPU on malformed payloads.
+    if req.repo_id.is_empty() || req.repo_id.len() > 64 {
+        return (StatusCode::FORBIDDEN, "invalid repo_id").into_response();
+    }
+    if req.repo_owner.len() != 64
+        || !req
+            .repo_owner
+            .chars()
+            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
+    {
+        return (StatusCode::FORBIDDEN, "invalid repo_owner").into_response();
+    }
+    if req.pusher_pubkey.len() != 64
+        || !req
+            .pusher_pubkey
+            .chars()
+            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
+    {
+        return (StatusCode::FORBIDDEN, "invalid pusher_pubkey").into_response();
+    }
+    if req.ref_updates.is_empty() || req.ref_updates.len() > 500 {
+        return (StatusCode::FORBIDDEN, "invalid ref_updates count").into_response();
+    }
+    for r in &req.ref_updates {
+        if r.old_oid.len() != 40 || !r.old_oid.chars().all(|c| c.is_ascii_hexdigit()) {
+            return (StatusCode::FORBIDDEN, "invalid old_oid").into_response();
+        }
+        if r.new_oid.len() != 40 || !r.new_oid.chars().all(|c| c.is_ascii_hexdigit()) {
+            return (StatusCode::FORBIDDEN, "invalid new_oid").into_response();
+        }
+        if r.ref_name.is_empty()
+            || r.ref_name.len() > 256
+            || !r.ref_name.starts_with("refs/")
+            || r.ref_name.contains("..")
+            || r.ref_name.bytes().any(|b| b <= 0x20 || b == 0x7f)
+        {
+            return (StatusCode::FORBIDDEN, "invalid ref_name").into_response();
+        }
+    }
+
+    // 2. Verify HMAC signature (now that we know the payload is structurally valid).
+    let secret = state.config.git_hook_hmac_secret.as_bytes();
+    if !verify_hmac(secret, &req) {
+        warn!(repo = %req.repo_id, "hook callback: HMAC verification failed");
+        return (StatusCode::FORBIDDEN, "signature verification failed").into_response();
+    }
+
+    // 3. Validate timestamp (30s TTL, max 5s future tolerance).
+    let now = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .unwrap_or_default()
+        .as_secs();
+    if now.saturating_sub(req.timestamp) > MAX_CALLBACK_AGE_SECS {
+        warn!(repo = %req.repo_id, age = now.saturating_sub(req.timestamp), "hook callback: expired");
+        return (StatusCode::FORBIDDEN, "callback expired").into_response();
+    }
+    if req.timestamp.saturating_sub(now) > 5 {
+        warn!(repo = %req.repo_id, "hook callback: timestamp too far in future");
+        return (StatusCode::FORBIDDEN, "callback timestamp invalid").into_response();
+    }
+
+    // 4. Validate and resolve kind:30617 for this repo.
+    // Query by (kind=30617, pubkey=owner, d_tag=repo_id) to prevent spoofing.
+    let owner_bytes = match hex::decode(&req.repo_owner) {
+        Ok(b) if b.len() == 32 => b,
+        _ => {
+            return (StatusCode::FORBIDDEN, "invalid repo owner").into_response();
+        }
+    };
+    let query = EventQuery {
+        kinds: Some(vec![30617]),
+        pubkey: Some(owner_bytes),
+        d_tag: Some(req.repo_id.clone()),
+        global_only: true,
+        limit: Some(1),
+        ..Default::default()
+    };
+    let repo_event = match state.db.query_events(&query).await {
+        Ok(mut events) => {
+            if let Some(event) = events.pop() {
+                event
+            } else {
+                warn!(repo = %req.repo_id, "hook callback: kind:30617 not found");
+                return (StatusCode::FORBIDDEN, "repository not found").into_response();
+            }
+        }
+        Err(e) => {
+            error!(repo = %req.repo_id, error = %e, "hook callback: DB error");
+            return (StatusCode::FORBIDDEN, "internal error").into_response();
+        }
+    };
+
+    // 5. Parse protection rules from kind:30617 tags.
+    let tags: Vec<Vec<String>> = repo_event
+        .event
+        .tags
+        .iter()
+        .map(|t| t.as_slice().to_vec())
+        .collect();
+
+    let rules = match parse_protection_tags(&tags) {
+        Ok(parsed) => {
+            // Log unknown rules as warnings (helps catch typos).
+            for unknown in &parsed.unknown_rules {
+                warn!(repo = %req.repo_id, rule = %unknown, "unknown sprout-protect rule (skipped)");
+            }
+            parsed.rules
+        }
+        Err(e) => {
+            warn!(repo = %req.repo_id, error = %e, "hook callback: malformed protection tags");
+            // Fail-closed: malformed rules = deny.
+            return (StatusCode::FORBIDDEN, "malformed protection rules").into_response();
+        }
+    };
+
+    // 6. Resolve channel and check archived state (applies to ALL pushers including owner).
+    let channel_id = tags
+        .iter()
+        .find(|t| t.first().map(|s| s.as_str()) == Some("sprout-channel"))
+        .and_then(|t| t.get(1))
+        .and_then(|id| Uuid::parse_str(id).ok());
+
+    if let Some(ch_id) = channel_id {
+        match state.db.get_channel(ch_id).await {
+            Ok(ch) if ch.archived_at.is_some() => {
+                return (StatusCode::FORBIDDEN, "channel is archived (read-only)").into_response();
+            }
+            Err(e) => {
+                error!(error = %e, "hook callback: channel lookup failed");
+                return (StatusCode::FORBIDDEN, "internal error").into_response();
+            }
+            _ => {} // Channel exists and is not archived.
+        }
+    }
+
+    // 7. Resolve pusher's role.
+    let repo_owner_hex = hex::encode(repo_event.event.pubkey.to_bytes());
+    let role = if req.pusher_pubkey == repo_owner_hex {
+        MemberRole::Owner
+    } else {
+        match channel_id {
+            None => {
+                warn!(repo = %req.repo_id, "hook callback: no sprout-channel binding");
+                return (StatusCode::FORBIDDEN, "no channel binding").into_response();
+            }
+            Some(ch_id) => {
+                let pusher_bytes = match hex::decode(&req.pusher_pubkey) {
+                    Ok(b) if b.len() == 32 => b,
+                    _ => {
+                        return (StatusCode::FORBIDDEN, "invalid pusher pubkey").into_response();
+                    }
+                };
+                match state.db.get_member_role(ch_id, &pusher_bytes).await {
+                    Ok(Some(role_str)) => match role_str.parse::<MemberRole>() {
+                        Ok(role) => role,
+                        Err(_) => {
+                            error!(role = %role_str, "hook callback: unknown role");
+                            return (StatusCode::FORBIDDEN, "internal error").into_response();
+                        }
+                    },
+                    Ok(None) => {
+                        return (StatusCode::FORBIDDEN, "not a channel member").into_response();
+                    }
+                    Err(e) => {
+                        error!(error = %e, "hook callback: role lookup failed");
+                        return (StatusCode::FORBIDDEN, "internal error").into_response();
+                    }
+                }
+            }
+        }
+    };
+
+    // 8. Effective git role: bots intentionally added to a channel push as members.
+    // Protection rules (push:admin, no-force-push, require-patch, etc.) still apply.
+    // Bot is a designation (what it is), not a permission tier (what it can do).
+    let git_role = match role {
+        MemberRole::Bot => MemberRole::Member,
+        other => other,
+    };
+
+    // 9. Classify ref updates and evaluate policy.
+    let updates: Vec<RefUpdate> = req
+        .ref_updates
+        .iter()
+        .map(|r| RefUpdate {
+            ref_name: r.ref_name.clone(),
+            kind: UpdateKind::classify(&r.old_oid, &r.new_oid, r.is_ancestor),
+            old_oid: r.old_oid.clone(),
+            new_oid: r.new_oid.clone(),
+        })
+        .collect();
+
+    match evaluate_push(&updates, git_role, &rules) {
+        Ok(()) => Json(HookCallbackResponse {
+            allowed: true,
+            denials: vec![],
+        })
+        .into_response(),
+        Err(denials) => {
+            let response = HookCallbackResponse {
+                allowed: false,
+                denials: denials.into_iter().map(DenialResponse::from).collect(),
+            };
+            (StatusCode::FORBIDDEN, Json(response)).into_response()
+        }
+    }
+}
+
+// ── HMAC Generation (for the relay to pass to the hook) ──────────────────────
+
+/// Generate the HMAC signature for a hook callback payload.
+///
+/// Called by the relay when setting up the pre-receive hook environment.
+pub fn generate_hook_hmac(
+    secret: &[u8],
+    repo_id: &str,
+    repo_owner: &str,
+    pusher_pubkey: &str,
+    ref_updates: &[HookRefUpdate],
+    timestamp: u64,
+) -> String {
+    let req = HookCallbackRequest {
+        repo_id: repo_id.to_string(),
+        repo_owner: repo_owner.to_string(),
+        pusher_pubkey: pusher_pubkey.to_string(),
+        ref_updates: ref_updates.to_vec(),
+        timestamp,
+        signature: String::new(), // Not used in computation.
+    };
+    let mac_bytes = compute_hmac(secret, &req);
+    hex::encode(mac_bytes)
+}
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn make_request() -> HookCallbackRequest {
+        HookCallbackRequest {
+            repo_id: "test-repo".to_string(),
+            repo_owner: "a".repeat(64),
+            pusher_pubkey: "b".repeat(64),
+            ref_updates: vec![HookRefUpdate {
+                old_oid: "1".repeat(40),
+                new_oid: "2".repeat(40),
+                ref_name: "refs/heads/main".to_string(),
+                is_ancestor: true,
+            }],
+            timestamp: SystemTime::now()
+                .duration_since(UNIX_EPOCH)
+                .unwrap()
+                .as_secs(),
+            signature: String::new(),
+        }
+    }
+
+    fn sign_request(req: &mut HookCallbackRequest, secret: &[u8]) {
+        let mac = compute_hmac(secret, req);
+        req.signature = hex::encode(mac);
+    }
+
+    #[test]
+    fn hmac_valid_signature_accepted() {
+        let secret = b"test-secret-key";
+        let mut req = make_request();
+        sign_request(&mut req, secret);
+        assert!(verify_hmac(secret, &req));
+    }
+
+    #[test]
+    fn hmac_wrong_secret_rejected() {
+        let mut req = make_request();
+        sign_request(&mut req, b"correct-secret");
+        assert!(!verify_hmac(b"wrong-secret", &req));
+    }
+
+    #[test]
+    fn hmac_tampered_repo_id_rejected() {
+        let secret = b"test-secret";
+        let mut req = make_request();
+        sign_request(&mut req, secret);
+        req.repo_id = "evil-repo".to_string();
+        assert!(!verify_hmac(secret, &req));
+    }
+
+    #[test]
+    fn hmac_tampered_pusher_rejected() {
+        let secret = b"test-secret";
+        let mut req = make_request();
+        sign_request(&mut req, secret);
+        req.pusher_pubkey = "c".repeat(64);
+        assert!(!verify_hmac(secret, &req));
+    }
+
+    #[test]
+    fn hmac_tampered_ref_rejected() {
+        let secret = b"test-secret";
+        let mut req = make_request();
+        sign_request(&mut req, secret);
+        req.ref_updates[0].ref_name = "refs/heads/evil".to_string();
+        assert!(!verify_hmac(secret, &req));
+    }
+
+    #[test]
+    fn hmac_tampered_is_ancestor_rejected() {
+        let secret = b"test-secret";
+        let mut req = make_request();
+        sign_request(&mut req, secret);
+        req.ref_updates[0].is_ancestor = false; // Flip FF → NFF
+        assert!(!verify_hmac(secret, &req));
+    }
+
+    #[test]
+    fn hmac_tampered_owner_rejected() {
+        let secret = b"test-secret";
+        let mut req = make_request();
+        sign_request(&mut req, secret);
+        req.repo_owner = "c".repeat(64);
+        assert!(!verify_hmac(secret, &req));
+    }
+
+    #[test]
+    fn hmac_tampered_timestamp_rejected() {
+        let secret = b"test-secret";
+        let mut req = make_request();
+        sign_request(&mut req, secret);
+        req.timestamp += 1;
+        assert!(!verify_hmac(secret, &req));
+    }
+
+    #[test]
+    fn hmac_invalid_hex_rejected() {
+        let secret = b"test-secret";
+        let mut req = make_request();
+        req.signature = "not-valid-hex!!!".to_string();
+        assert!(!verify_hmac(secret, &req));
+    }
+
+    #[test]
+    fn hmac_deterministic_across_ref_order() {
+        let secret = b"test-secret";
+        let mut req1 = make_request();
+        req1.ref_updates.push(HookRefUpdate {
+            old_oid: "3".repeat(40),
+            new_oid: "4".repeat(40),
+            ref_name: "refs/heads/develop".to_string(),
+            is_ancestor: false,
+        });
+        let mut req2 = req1.clone();
+        // Reverse the ref order — HMAC should be the same (sorted internally).
+        req2.ref_updates.reverse();
+        let mac1 = compute_hmac(secret, &req1);
+        let mac2 = compute_hmac(secret, &req2);
+        assert_eq!(mac1, mac2);
+    }
+
+    #[test]
+    fn generate_hook_hmac_matches_verify() {
+        let secret = b"test-secret";
+        let mut req = make_request();
+        let sig = generate_hook_hmac(
+            secret,
+            &req.repo_id,
+            &req.repo_owner,
+            &req.pusher_pubkey,
+            &req.ref_updates,
+            req.timestamp,
+        );
+        req.signature = sig;
+        assert!(verify_hmac(secret, &req));
+    }
+
+    /// Cross-boundary HMAC integration test.
+    ///
+    /// Runs the bash HMAC computation logic (extracted from the pre-receive hook)
+    /// and compares its output against Rust's `generate_hook_hmac`. This is the
+    /// most critical test — it verifies the bash/Rust format agreement that the
+    /// entire security model depends on.
+    #[test]
+    fn bash_hmac_matches_rust_hmac() {
+        let secret = "cross-boundary-test-secret-key-1234";
+        let repo_id = "my-project";
+        let repo_owner = "ab".repeat(32); // 64 hex chars
+        let pusher = "cd".repeat(32); // 64 hex chars
+        let timestamp: u64 = 1700000000;
+
+        // Two refs, intentionally out of sorted order to test sorting.
+        let ref_updates = vec![
+            HookRefUpdate {
+                old_oid: "b".repeat(40),
+                new_oid: "c".repeat(40),
+                ref_name: "refs/heads/main".to_string(),
+                is_ancestor: true,
+            },
+            HookRefUpdate {
+                old_oid: "a".repeat(40),
+                new_oid: "d".repeat(40),
+                ref_name: "refs/heads/feature".to_string(),
+                is_ancestor: false,
+            },
+        ];
+
+        // Compute Rust-side HMAC.
+        let rust_sig = generate_hook_hmac(
+            secret.as_bytes(),
+            repo_id,
+            &repo_owner,
+            &pusher,
+            &ref_updates,
+            timestamp,
+        );
+
+        // Bash script that replicates the hook's HMAC computation.
+        // This is the exact logic from hook.rs PRE_RECEIVE_HOOK, extracted into
+        // a standalone script with hardcoded values.
+        let bash_script = format!(
+            r#"
+export LC_ALL=C
+SPROUT_REPO_ID="{repo_id}"
+SPROUT_REPO_OWNER="{repo_owner}"
+SPROUT_PUSHER_PUBKEY="{pusher}"
+SPROUT_HOOK_SECRET="{secret}"
+TIMESTAMP="{timestamp}"
+
+# Simulate the HMAC_FILE with two refs (unsorted, like the hook writes them)
+WORK_DIR=$(mktemp -d)
+trap 'rm -rf "$WORK_DIR"' EXIT
+HMAC_FILE="$WORK_DIR/hmac"
+
+# Write refs in the order they'd arrive (main first, feature second)
+echo "refs/heads/main {old1} {new1} 1" >> "$HMAC_FILE"
+echo "refs/heads/feature {old2} {new2} 0" >> "$HMAC_FILE"
+
+# Build HMAC input — exact logic from hook script
+REPO_ID_LEN=${{#SPROUT_REPO_ID}}
+HMAC_INPUT="${{REPO_ID_LEN}}:${{SPROUT_REPO_ID}}|${{SPROUT_REPO_OWNER}}|${{SPROUT_PUSHER_PUBKEY}}|"
+sort "$HMAC_FILE" | while IFS=' ' read -r ref_name old_oid new_oid is_anc; do
+    REF_LEN=${{#ref_name}}
+    printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc"
+done > "$HMAC_FILE.concat"
+HMAC_INPUT="${{HMAC_INPUT}}$(cat "$HMAC_FILE.concat")|${{TIMESTAMP}}"
+
+# Compute HMAC-SHA256
+printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "$SPROUT_HOOK_SECRET" -hex 2>/dev/null | sed 's/.*= //'
+"#,
+            repo_id = repo_id,
+            repo_owner = repo_owner,
+            pusher = pusher,
+            secret = secret,
+            timestamp = timestamp,
+            old1 = "b".repeat(40),
+            new1 = "c".repeat(40),
+            old2 = "a".repeat(40),
+            new2 = "d".repeat(40),
+        );
+
+        let output = std::process::Command::new("bash")
+            .arg("-c")
+            .arg(&bash_script)
+            .output()
+            .expect("failed to run bash");
+
+        assert!(
+            output.status.success(),
+            "bash script failed: {}",
+            String::from_utf8_lossy(&output.stderr)
+        );
+
+        let bash_sig = String::from_utf8_lossy(&output.stdout).trim().to_string();
+
+        assert_eq!(
+            rust_sig, bash_sig,
+            "HMAC mismatch!\n  Rust: {rust_sig}\n  Bash: {bash_sig}\n\
+             The pre-receive hook and policy endpoint disagree on the canonical format."
+        );
+    }
+
+    /// Cross-boundary test with a single ref (simpler case).
+    #[test]
+    fn bash_hmac_single_ref() {
+        let secret = "single-ref-secret";
+        let repo_id = "test-repo";
+        let repo_owner = "a".repeat(64);
+        let pusher = "b".repeat(64);
+        let timestamp: u64 = 1700000001;
+
+        let ref_updates = vec![HookRefUpdate {
+            old_oid: "1".repeat(40),
+            new_oid: "2".repeat(40),
+            ref_name: "refs/heads/main".to_string(),
+            is_ancestor: true,
+        }];
+
+        let rust_sig = generate_hook_hmac(
+            secret.as_bytes(),
+            repo_id,
+            &repo_owner,
+            &pusher,
+            &ref_updates,
+            timestamp,
+        );
+
+        let bash_script = format!(
+            r#"
+export LC_ALL=C
+WORK_DIR=$(mktemp -d)
+trap 'rm -rf "$WORK_DIR"' EXIT
+HMAC_FILE="$WORK_DIR/hmac"
+echo "refs/heads/main {old} {new} 1" >> "$HMAC_FILE"
+SPROUT_REPO_ID="{repo_id}"
+REPO_ID_LEN=${{#SPROUT_REPO_ID}}
+HMAC_INPUT="${{REPO_ID_LEN}}:${{SPROUT_REPO_ID}}|{owner}|{pusher}|"
+sort "$HMAC_FILE" | while IFS=' ' read -r ref_name old_oid new_oid is_anc; do
+    REF_LEN=${{#ref_name}}
+    printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc"
+done > "$HMAC_FILE.concat"
+HMAC_INPUT="${{HMAC_INPUT}}$(cat "$HMAC_FILE.concat")|{timestamp}"
+printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "{secret}" -hex 2>/dev/null | sed 's/.*= //'
+"#,
+            old = "1".repeat(40),
+            new = "2".repeat(40),
+            repo_id = repo_id,
+            owner = repo_owner,
+            pusher = pusher,
+            timestamp = timestamp,
+            secret = secret,
+        );
+
+        let output = std::process::Command::new("bash")
+            .arg("-c")
+            .arg(&bash_script)
+            .output()
+            .expect("failed to run bash");
+
+        assert!(
+            output.status.success(),
+            "bash script failed: {}",
+            String::from_utf8_lossy(&output.stderr)
+        );
+
+        let bash_sig = String::from_utf8_lossy(&output.stdout).trim().to_string();
+        assert_eq!(
+            rust_sig, bash_sig,
+            "Single-ref HMAC mismatch!\n  Rust: {rust_sig}\n  Bash: {bash_sig}"
+        );
+    }
+}
diff --git a/crates/sprout-relay/src/api/git.rs b/crates/sprout-relay/src/api/git/transport.rs
similarity index 76%
rename from crates/sprout-relay/src/api/git.rs
rename to crates/sprout-relay/src/api/git/transport.rs
index 06821a225..cb73de8a7 100644
--- a/crates/sprout-relay/src/api/git.rs
+++ b/crates/sprout-relay/src/api/git/transport.rs
@@ -42,8 +42,9 @@ const PACK_OPS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300
 /// Validates the `Authorization: Nostr <base64>` header before the request body
 /// is read. Same pattern as `AuthenticatedUpload` in media.rs.
 ///
-/// Authorization model (v1): any authenticated pubkey can clone; only the repo
-/// owner can push. Maintainer lists from kind:30617 are a future enhancement.
+/// Authorization model: any authenticated pubkey can clone; push authorization
+/// is handled by the pre-receive hook (calls back to the internal policy endpoint
+/// which checks channel role + protection rules from kind:30617).
 pub struct GitAuth {
     /// The authenticated user's public key, extracted from the NIP-98 event.
     pub pubkey: nostr::PublicKey,
@@ -103,32 +104,39 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
             .map(|pq| pq.as_str())
             .unwrap_or(parts.uri.path());
 
-        // Bug fix: strip git endpoint suffixes before building the expected URL.
+        // Repo-root URL verification.
         //
-        // Git's credential helper is invoked once — for the initial GET /info/refs —
-        // and signs a NIP-98 token with `u = <repo-root>` (e.g. `/git/{owner}/{repo}.git`).
-        // That same token is reused for all subsequent requests in the session
-        // (git-upload-pack, git-receive-pack). We must verify against the repo-root URL,
-        // not the full endpoint URL, so all three endpoints share a single canonical URL.
-        let repo_path = path_and_query
-            .split_once("/info/refs")
-            .map(|(prefix, _)| prefix)
-            .or_else(|| path_and_query.strip_suffix("/git-upload-pack"))
-            .or_else(|| path_and_query.strip_suffix("/git-receive-pack"))
-            .unwrap_or(path_and_query);
+        // The credential helper signs a NIP-98 token with:
+        //   u = <repo-root>   (e.g., http://host/git/{owner}/{repo})
+        //
+        // Git's credential protocol does NOT pass query strings to helpers, so
+        // service-scoping (`?service=...`) cannot be implemented at the NIP-98
+        // level without protocol changes. The token is repo-scoped, not service-scoped.
+        //
+        // Security is still provided by:
+        // - ±60s timestamp window (limits replay)
+        // - HTTPS in production (prevents token theft)
+        // - Pre-receive hook for push authorization (role + protection rules)
+        // - Endpoint routing (clone/push are different HTTP paths)
+        let repo_path = if let Some((prefix, _query)) = path_and_query.split_once("/info/refs") {
+            prefix
+        } else if let Some(prefix) = path_and_query.strip_suffix("/git-upload-pack") {
+            prefix
+        } else if let Some(prefix) = path_and_query.strip_suffix("/git-receive-pack") {
+            prefix
+        } else {
+            return Err((StatusCode::BAD_REQUEST, "unrecognized git endpoint").into_response());
+        };
         let expected_url = format!("{base_url}{repo_path}");
 
-        // Bug fix: skip the HTTP method check for git routes.
+        // Skip HTTP method check for git routes.
         //
-        // Git's credential helper signs the NIP-98 token with `method=GET` (the method
-        // used for the initial /info/refs discovery request), then reuses that same token
-        // for POST requests (git-upload-pack, git-receive-pack). Verifying against the
-        // actual HTTP method would reject all pack operations after the first request.
+        // Git's credential helper signs with `method=GET` (the initial /info/refs request)
+        // then reuses the token for POST (pack data). Method binding can't work here.
         //
-        // The URL tag already scopes the token to the correct repo — the method tag adds
-        // no meaningful security here since all git endpoints require the same authorization
-        // (repo owner). We pass the method from the event itself so verify_nip98_event
-        // always accepts whatever method the credential helper signed.
+        // Security is provided by: service-binding in the URL (clone vs push scoped),
+        // ±60s timestamp, and the pre-receive hook for push authorization.
+        // We pass the method from the event itself so verify_nip98_event always accepts.
         let event_method = serde_json::from_str::<serde_json::Value>(&event_json)
             .ok()
             .and_then(|v| {
@@ -361,19 +369,17 @@ pub async fn upload_pack(
 /// `POST /git/{owner}/{repo}/git-receive-pack`
 ///
 /// Handles push — client sends ref updates + pack data.
-/// Authorization: only the repo owner (whose pubkey matches `{owner}` in the URL)
-/// can push. Maintainer lists from kind:30617 are a future enhancement.
+/// Authorization: NIP-98 authenticates the pusher. The pre-receive hook
+/// calls back to the internal policy endpoint for ref-level authorization
+/// (channel role + protection rules). Any authenticated user can attempt a push;
+/// the hook enforces the actual permissions.
 pub async fn receive_pack(
     State(state): State<Arc<AppState>>,
     auth: GitAuth,
     AxumPath(params): AxumPath<GitRepoParams>,
     body: Body,
 ) -> Result<Response, Response> {
-    // Push authorization: authenticated pubkey must match the repo owner.
     let pusher_hex = hex::encode(auth.pubkey.serialize());
-    if pusher_hex != params.owner {
-        return Err((StatusCode::FORBIDDEN, "push denied: not the repo owner").into_response());
-    }
 
     // Per-repo lock: prevent concurrent pushes to the same bare repo.
     // git receive-pack is not safe for concurrent access.
@@ -385,15 +391,96 @@ pub async fn receive_pack(
         .clone();
     let _repo_guard = repo_lock.lock().await;
 
-    let response =
-        run_git_service(&state, &params.owner, &params.repo, "receive-pack", body).await?;
+    // SECURITY: Verify pre-receive hook is a regular file, executable, and not a symlink.
+    // If the hook is missing, non-executable, or a symlink (potential tampering),
+    // deny the push rather than allowing it without permission checks.
+    let hook_path = validated.repo_path.join("hooks").join("pre-receive");
+    {
+        let hook_ok = {
+            #[cfg(unix)]
+            {
+                use std::os::unix::fs::PermissionsExt;
+                // Use symlink_metadata to detect symlinks (doesn't follow them).
+                std::fs::symlink_metadata(&hook_path)
+                    .map(|m| {
+                        m.file_type().is_file() // Regular file, not symlink
+                            && m.permissions().mode() & 0o111 != 0 // Executable
+                    })
+                    .unwrap_or(false)
+            }
+            #[cfg(not(unix))]
+            {
+                hook_path.is_file()
+            }
+        };
+        if !hook_ok {
+            warn!(
+                repo = %params.repo,
+                hook = %hook_path.display(),
+                "push denied: pre-receive hook missing, not executable, or symlink"
+            );
+            return Err((
+                StatusCode::INTERNAL_SERVER_ERROR,
+                "push denied: repository permission hook not installed",
+            )
+                .into_response());
+        }
+    }
+
+    // Resolve repo name (strip .git suffix if present).
+    let repo_name = params.repo.strip_suffix(".git").unwrap_or(&params.repo);
 
-    // Post-push: publish kind:30618 ref state event (fire-and-forget).
+    // Build hook env vars for the pre-receive hook.
+    // The hook uses these to call back to the internal policy endpoint.
+    let hook_url = format!(
+        "http://127.0.0.1:{}/internal/git/policy",
+        state.config.bind_addr.port()
+    );
+    // SECURITY: Force core.hooksPath via env to prevent repo-local config from
+    // overriding the hook directory. Without this, a malicious repo config could
+    // set core.hooksPath=/dev/null to bypass the pre-receive hook entirely.
+    let hooks_dir = validated.repo_path.join("hooks").display().to_string();
+    let hook_env = vec![
+        ("SPROUT_HOOK_URL", hook_url),
+        (
+            "SPROUT_HOOK_SECRET",
+            state.config.git_hook_hmac_secret.clone(),
+        ),
+        ("SPROUT_REPO_ID", repo_name.to_string()),
+        ("SPROUT_REPO_OWNER", params.owner.clone()),
+        ("SPROUT_PUSHER_PUBKEY", pusher_hex.clone()),
+        // Override any repo-local core.hooksPath setting.
+        ("GIT_CONFIG_COUNT", "1".to_string()),
+        ("GIT_CONFIG_KEY_0", "core.hooksPath".to_string()),
+        ("GIT_CONFIG_VALUE_0", hooks_dir),
+    ];
+
+    // Snapshot refs before push — used to detect whether anything actually changed.
+    let refs_before = snapshot_refs(&validated.repo_path).await;
+
+    let response = run_git_service_with_env(
+        &state,
+        &params.owner,
+        &params.repo,
+        "receive-pack",
+        body,
+        &hook_env,
+    )
+    .await?;
+
+    // Post-push: publish kind:30618 ref state only if refs actually changed.
+    // Git smart HTTP returns 200 even on denied pushes (in-band rejection),
+    // so we compare before/after refs to avoid publishing on no-ops.
     let state_clone = state.clone();
     let owner = params.owner.clone();
     let repo = params.repo.clone();
     let pusher = auth.pubkey;
+    let repo_path = validated.repo_path.clone();
     tokio::spawn(async move {
+        let refs_after = snapshot_refs(&repo_path).await;
+        if refs_before == refs_after {
+            return; // Nothing changed — skip publish.
+        }
         if let Err(e) = publish_ref_state(&state_clone, &owner, &repo, &pusher).await {
             warn!(error = %e, owner = %owner, repo = %repo, "failed to publish kind:30618");
         }
@@ -409,6 +496,21 @@ async fn run_git_service(
     repo: &str,
     service: &str,
     body: Body,
+) -> Result<Response, Response> {
+    run_git_service_with_env(state, owner, repo, service, body, &[]).await
+}
+
+/// Shared git service runner with extra environment variables.
+///
+/// The `extra_env` pairs are set AFTER `harden_git_env` clears the environment,
+/// so they're available to the git subprocess and any hooks it spawns.
+async fn run_git_service_with_env(
+    state: &Arc<AppState>,
+    owner: &str,
+    repo: &str,
+    service: &str,
+    body: Body,
+    extra_env: &[(&str, String)],
 ) -> Result<Response, Response> {
     let validated = validate_repo_path(owner, repo, &state.config.git_repo_path)?;
     if !validated.repo_path.exists() {
@@ -432,6 +534,10 @@ async fn run_git_service(
         .stderr(std::process::Stdio::piped())
         .kill_on_drop(true);
     harden_git_env(&mut cmd);
+    // Pass extra env vars (e.g., hook callback URL and HMAC secret).
+    for (key, value) in extra_env {
+        cmd.env(key, value);
+    }
     let mut child = cmd.spawn().map_err(|e| {
         error!(error = %e, "git subprocess failed to spawn");
         (StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
@@ -501,6 +607,24 @@ async fn run_git_service(
 
 // ── Post-Push Event Publishing ───────────────────────────────────────────────
 
+/// Quick snapshot of current refs — used to detect whether a push changed anything.
+///
+/// Returns the raw `git for-each-ref` output as a string. Comparison is by
+/// string equality — cheap and sufficient (same refs + same SHAs = same string).
+/// Returns empty string on error (conservative: will trigger publish on failure).
+async fn snapshot_refs(repo_path: &std::path::Path) -> String {
+    let mut cmd = Command::new("git");
+    cmd.args(["for-each-ref", "--format=%(refname) %(objectname)"])
+        .current_dir(repo_path);
+    harden_git_env(&mut cmd);
+    match cmd.output().await {
+        Ok(output) if output.status.success() => {
+            String::from_utf8_lossy(&output.stdout).into_owned()
+        }
+        _ => String::new(), // Error → empty → won't match after → publish fires (safe default)
+    }
+}
+
 /// Publish kind:30618 (repo state) after a successful push.
 ///
 /// Reads current refs from the repo and publishes a relay-signed event
diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs
index 37323ffaf..ce1c5f78a 100644
--- a/crates/sprout-relay/src/config.rs
+++ b/crates/sprout-relay/src/config.rs
@@ -11,6 +11,9 @@ pub enum ConfigError {
     /// The `SPROUT_BIND_ADDR` environment variable could not be parsed as a socket address.
     #[error("invalid SPROUT_BIND_ADDR: {0}")]
     InvalidBindAddr(String),
+    /// A configuration value failed validation.
+    #[error("invalid config: {0}")]
+    InvalidValue(String),
 }
 
 /// Relay runtime configuration, loaded from environment variables.
@@ -79,6 +82,9 @@ pub struct Config {
     pub git_max_repos_per_pubkey: u32,
     /// Maximum concurrent git subprocess operations. Default: 20.
     pub git_max_concurrent_ops: usize,
+    /// HMAC secret for git pre-receive hook callbacks.
+    /// Used to authenticate internal policy endpoint requests.
+    pub git_hook_hmac_secret: String,
 }
 
 impl Config {
@@ -244,6 +250,21 @@ impl Config {
             .ok()
             .and_then(|v| v.parse().ok())
             .unwrap_or(20);
+        let git_hook_hmac_secret: String = std::env::var("SPROUT_GIT_HOOK_HMAC_SECRET")
+            .unwrap_or_else(|_| {
+                // Generate a random secret if not configured (dev mode).
+                let secret: [u8; 32] = rand::random();
+                hex::encode(secret)
+            });
+        // Reject explicitly-configured secrets that are too short.
+        // The auto-generated fallback is always 64 hex chars (32 bytes), so this
+        // only fires when someone sets SPROUT_GIT_HOOK_HMAC_SECRET to a weak value.
+        if std::env::var("SPROUT_GIT_HOOK_HMAC_SECRET").is_ok() && git_hook_hmac_secret.len() < 32 {
+            return Err(ConfigError::InvalidValue(
+                "SPROUT_GIT_HOOK_HMAC_SECRET must be at least 32 characters (16 bytes hex)"
+                    .to_string(),
+            ));
+        }
 
         Ok(Self {
             bind_addr,
@@ -269,6 +290,7 @@ impl Config {
             git_max_pack_bytes,
             git_max_repos_per_pubkey,
             git_max_concurrent_ops,
+            git_hook_hmac_secret,
         })
     }
 }
diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs
index 5f0fbe124..4cf5b6d10 100644
--- a/crates/sprout-relay/src/handlers/side_effects.rs
+++ b/crates/sprout-relay/src/handlers/side_effects.rs
@@ -1453,7 +1453,7 @@ fn validate_repo_id(repo_id: &str) -> bool {
 /// - Repo name validated: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`
 /// - Owner pubkey validated: exactly 64 lowercase hex chars
 /// - Path canonicalized and verified to start with repo root
-/// - Git hooks disabled via `core.hooksPath=/dev/null` + hooks dir removed
+/// - Pre-receive hook installed for permission enforcement (only hook enabled)
 /// - Per-pubkey repo count limit enforced
 async fn handle_git_repo_announcement(event: &Event, state: &Arc<AppState>) -> anyhow::Result<()> {
     use tokio::process::Command;
@@ -1594,19 +1594,6 @@ async fn handle_git_repo_announcement(event: &Event, state: &Arc<AppState>) -> a
         return Err(anyhow::anyhow!("git init --bare failed: {stderr}"));
     }
 
-    // Security: disable git hooks (RCE prevention).
-    let _ = Command::new("git")
-        .args(["config", "--file"])
-        .arg(repo_dir.join("config"))
-        .args(["core.hooksPath", "/dev/null"])
-        .env_clear()
-        .env("PATH", std::env::var("PATH").unwrap_or_default())
-        .env("GIT_CONFIG_NOSYSTEM", "1")
-        .env("GIT_CONFIG_GLOBAL", "/dev/null")
-        .env("HOME", "/dev/null")
-        .output()
-        .await;
-
     // Git config for Smart HTTP compatibility.
     for (key, value) in [
         ("http.receivepack", "true"),
@@ -1627,11 +1614,22 @@ async fn handle_git_repo_announcement(event: &Event, state: &Arc<AppState>) -> a
             .await;
     }
 
-    // Belt-and-suspenders: remove hooks directory entirely.
-    let hooks_dir = repo_dir.join("hooks");
-    if hooks_dir.exists() {
-        let _ = tokio::fs::remove_dir_all(&hooks_dir).await;
-    }
+    // Install pre-receive hook for permission enforcement.
+    // This replaces the old "disable all hooks" approach — we now have our own
+    // hook that calls back to the relay's internal policy endpoint.
+    // Only pre-receive is installed; all other hook slots remain empty (RCE prevention).
+    // SECURITY: Hook installation is FATAL. If the hook can't be installed,
+    // the repo would be unprotected. Better to fail repo creation than allow
+    // an unprotected repo to exist. The receive_pack handler also checks hook
+    // existence as a belt-and-suspenders measure.
+    crate::api::git::hook::install_hook(&repo_dir)
+        .await
+        .map_err(|e| {
+            // Clean up the repo directory since it's unusable without the hook.
+            let _ = std::fs::remove_dir_all(&repo_dir);
+            let _ = std::fs::remove_dir(&reservation);
+            anyhow::anyhow!("failed to install pre-receive hook: {e}")
+        })?;
 
     info!(
         repo_id = %repo_id,
diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs
index 118d78690..ce5f9f890 100644
--- a/crates/sprout-relay/src/router.rs
+++ b/crates/sprout-relay/src/router.rs
@@ -53,6 +53,9 @@ pub fn build_router(state: Arc<AppState>) -> Router {
     // ── Git routes: configurable body limit (default 500 MB) ─────────────────
     let git_router = api::git::git_router(state.clone());
 
+    // ── Internal git policy route (pre-receive hook callback) ────────────────
+    let git_policy_router = api::git::git_policy_router(state.clone());
+
     // ── All other routes: 1 MB body limit ────────────────────────────────────
     let api_router = Router::new()
         .route("/", get(nip11_or_ws_handler))
@@ -168,6 +171,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
     api_router
         .merge(media_router)
         .merge(git_router)
+        .merge(git_policy_router)
         .layer(middleware::from_fn(track_metrics))
         .layer(TraceLayer::new_for_http())
         .layer(build_cors_layer(&state.config.cors_origins))
diff --git a/scripts/e2e-git-perms.sh b/scripts/e2e-git-perms.sh
new file mode 100755
index 000000000..a63411ed4
--- /dev/null
+++ b/scripts/e2e-git-perms.sh
@@ -0,0 +1,535 @@
+#!/usr/bin/env bash
+# =============================================================================
+# e2e-git-perms.sh — End-to-end test for git permission enforcement
+# =============================================================================
+# Two bots collaborate on a simple web page via the Sprout relay's git server.
+#
+# Prerequisites:
+#   - Docker services running (postgres, redis, typesense)
+#   - Relay built: cargo build --release --bin sprout-relay
+#   - Credential helper built: cargo build --release --bin git-credential-nostr
+#
+# What it tests:
+#   1. Owner creates a repo (kind:30617) and a channel
+#   2. Owner adds two bots to the channel
+#   3. Bot1 clones, creates index.html, pushes (should succeed)
+#   4. Bot2 clones, modifies index.html, pushes (should succeed)
+#   5. Guest tries to push (should be denied)
+#   6. Owner adds protection rule (push:admin on main), bot1 push denied
+#   7. Admin bot2 promoted, can still push
+# =============================================================================
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+cd "${REPO_ROOT}"
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m'
+
+log()     { echo -e "${BLUE}[e2e-git]${NC} $*"; }
+success() { echo -e "${GREEN}[e2e-git]${NC} ✓ $*"; }
+fail()    { echo -e "${RED}[e2e-git]${NC} ✗ $*" >&2; cleanup; exit 1; }
+warn()    { echo -e "${YELLOW}[e2e-git]${NC} $*"; }
+
+# ── Cleanup ───────────────────────────────────────────────────────────────────
+
+RELAY_PID=""
+WORK_DIR=""
+
+cleanup() {
+    if [[ -n "$RELAY_PID" ]]; then
+        kill "$RELAY_PID" 2>/dev/null || true
+        wait "$RELAY_PID" 2>/dev/null || true
+    fi
+    if [[ -n "$WORK_DIR" ]]; then
+        rm -rf "$WORK_DIR"
+    fi
+}
+trap cleanup EXIT
+
+# ── Generate keypairs ─────────────────────────────────────────────────────────
+
+generate_keypair() {
+    # Use openssl to generate a 32-byte random hex string as private key
+    local privkey
+    privkey=$(openssl rand -hex 32)
+    echo "$privkey"
+}
+
+# Derive pubkey from privkey using nostr crate via a tiny inline program
+# Actually, let's use the sprout-test-cli or python for this
+derive_pubkey() {
+    local privkey="$1"
+    # Use python3 with secp256k1 to derive the x-only pubkey
+    python3 -c "
+import hashlib, struct
+
+def privkey_to_pubkey(privkey_hex):
+    # secp256k1 parameters
+    P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
+    N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
+    Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
+    Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
+
+    def point_add(p1, p2):
+        if p1 is None: return p2
+        if p2 is None: return p1
+        x1, y1 = p1
+        x2, y2 = p2
+        if x1 == x2 and y1 != y2: return None
+        if x1 == x2:
+            lam = (3 * x1 * x1) * pow(2 * y1, P - 2, P) % P
+        else:
+            lam = (y2 - y1) * pow(x2 - x1, P - 2, P) % P
+        x3 = (lam * lam - x1 - x2) % P
+        y3 = (lam * (x1 - x3) - y1) % P
+        return (x3, y3)
+
+    def scalar_mult(k, point):
+        result = None
+        addend = point
+        while k:
+            if k & 1:
+                result = point_add(result, addend)
+            addend = point_add(addend, addend)
+            k >>= 1
+        return result
+
+    k = int(privkey_hex, 16)
+    pub = scalar_mult(k, (Gx, Gy))
+    return format(pub[0], '064x')
+
+print(privkey_to_pubkey('$privkey'))
+"
+}
+
+# ── Start relay ───────────────────────────────────────────────────────────────
+
+log "Starting relay..."
+
+# Load env
+if [[ -f .env ]]; then
+    set -o allexport
+    source .env
+    set +o allexport
+fi
+
+export SPROUT_GIT_REPO_PATH="${REPO_ROOT}/repos"
+export SPROUT_GIT_HOOK_HMAC_SECRET="e2e-test-secret-that-is-long-enough-for-validation-purposes"
+export SPROUT_BIND_ADDR="0.0.0.0:3000"
+export RELAY_URL="ws://localhost:3000"
+export RUST_LOG="sprout_relay=warn"
+export SPROUT_REQUIRE_AUTH_TOKEN=false
+
+# Clean repos dir
+rm -rf "${REPO_ROOT}/repos"
+mkdir -p "${REPO_ROOT}/repos"
+
+# Kill any existing relay
+pkill -f "sprout-relay" 2>/dev/null || true
+sleep 1
+
+./target/release/sprout-relay > /tmp/sprout-relay-e2e.log 2>&1 &
+RELAY_PID=$!
+
+# Wait for relay
+for i in $(seq 1 15); do
+    if curl -s http://localhost:3000/ -H "Accept: application/nostr+json" | grep -q "Sprout"; then
+        break
+    fi
+    if [[ $i -eq 15 ]]; then
+        fail "Relay did not start. Check /tmp/sprout-relay-e2e.log"
+    fi
+    sleep 1
+done
+success "Relay started (PID $RELAY_PID)"
+
+# ── Generate identities ──────────────────────────────────────────────────────
+
+log "Generating keypairs..."
+
+OWNER_PRIVKEY=$(generate_keypair)
+OWNER_PUBKEY=$(derive_pubkey "$OWNER_PRIVKEY")
+BOT1_PRIVKEY=$(generate_keypair)
+BOT1_PUBKEY=$(derive_pubkey "$BOT1_PRIVKEY")
+BOT2_PRIVKEY=$(generate_keypair)
+BOT2_PUBKEY=$(derive_pubkey "$BOT2_PRIVKEY")
+GUEST_PRIVKEY=$(generate_keypair)
+GUEST_PUBKEY=$(derive_pubkey "$GUEST_PRIVKEY")
+
+log "  Owner:  ${OWNER_PUBKEY:0:16}..."
+log "  Bot1:   ${BOT1_PUBKEY:0:16}..."
+log "  Bot2:   ${BOT2_PUBKEY:0:16}..."
+log "  Guest:  ${GUEST_PUBKEY:0:16}..."
+
+# ── Work directory ────────────────────────────────────────────────────────────
+
+WORK_DIR=$(mktemp -d)
+log "Work dir: $WORK_DIR"
+
+# ── Helper: sign and send nostr event via websocket ───────────────────────────
+
+# We'll use python3 + websockets for the nostr protocol interactions
+send_event() {
+    local privkey="$1"
+    local kind="$2"
+    local content="$3"
+    shift 3
+    local tags_json="$*"
+
+    python3 << PYEOF
+import json, hashlib, time, struct, secrets
+import websocket
+
+P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
+N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
+Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
+Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
+
+def point_add(p1, p2):
+    if p1 is None: return p2
+    if p2 is None: return p1
+    x1, y1 = p1; x2, y2 = p2
+    if x1 == x2 and y1 != y2: return None
+    if x1 == x2: lam = (3*x1*x1) * pow(2*y1, P-2, P) % P
+    else: lam = (y2-y1) * pow(x2-x1, P-2, P) % P
+    x3 = (lam*lam - x1 - x2) % P
+    y3 = (lam*(x1-x3) - y1) % P
+    return (x3, y3)
+
+def scalar_mult(k, point):
+    result = None; addend = point
+    while k:
+        if k & 1: result = point_add(result, addend)
+        addend = point_add(addend, addend)
+        k >>= 1
+    return result
+
+def sign_schnorr(privkey_bytes, msg_bytes):
+    k_int = int.from_bytes(privkey_bytes, 'big')
+    pubpoint = scalar_mult(k_int, (Gx, Gy))
+    pubkey_bytes = pubpoint[0].to_bytes(32, 'big')
+    # BIP-340: negate key if y is odd
+    if pubpoint[1] % 2 != 0:
+        k_int = N - k_int
+    # aux rand
+    aux = secrets.token_bytes(32)
+    t = bytes(a ^ b for a, b in zip(k_int.to_bytes(32, 'big'), hashlib.sha256(b'BIP0340/aux' + b'BIP0340/aux' + aux).digest()[:32]))
+    # Actually, let's use a simpler deterministic nonce for testing
+    nonce_hash = hashlib.sha256(k_int.to_bytes(32, 'big') + msg_bytes).digest()
+    r_int = int.from_bytes(nonce_hash, 'big') % N
+    if r_int == 0: raise Exception("bad nonce")
+    R = scalar_mult(r_int, (Gx, Gy))
+    if R[1] % 2 != 0:
+        r_int = N - r_int
+    R_bytes = R[0].to_bytes(32, 'big')
+    e_hash = hashlib.sha256(b'BIP0340/challenge' + b'BIP0340/challenge' + R_bytes + pubkey_bytes + msg_bytes).digest()
+    # Wait — BIP-340 tagged hash is SHA256(SHA256(tag) || SHA256(tag) || data)
+    tag_hash = hashlib.sha256(b'BIP0340/challenge').digest()
+    e_hash = hashlib.sha256(tag_hash + tag_hash + R_bytes + pubkey_bytes + msg_bytes).digest()
+    e_int = int.from_bytes(e_hash, 'big') % N
+    s_int = (r_int + e_int * k_int) % N
+    return R_bytes + s_int.to_bytes(32, 'big')
+
+privkey = bytes.fromhex("${privkey}")
+pubpoint = scalar_mult(int.from_bytes(privkey, 'big'), (Gx, Gy))
+pubkey_hex = format(pubpoint[0], '064x')
+
+created_at = int(time.time())
+tags = json.loads('${tags_json}') if '${tags_json}'.strip() else []
+content = """${content}"""
+
+# Serialize for ID
+serialized = json.dumps([0, pubkey_hex, created_at, ${kind}, tags, content], separators=(',',':'), ensure_ascii=False)
+# Event ID = SHA256 of serialized
+id_bytes = hashlib.sha256(serialized.encode()).digest()
+event_id = id_bytes.hex()
+
+# Sign
+sig = sign_schnorr(privkey, id_bytes)
+
+event = {
+    "id": event_id,
+    "pubkey": pubkey_hex,
+    "created_at": created_at,
+    "kind": ${kind},
+    "tags": tags,
+    "content": content,
+    "sig": sig.hex()
+}
+
+# Send via websocket
+ws = websocket.create_connection("ws://localhost:3000")
+# Read AUTH challenge
+msg = json.loads(ws.recv())
+if msg[0] == "AUTH":
+    # Authenticate
+    challenge = msg[1]
+    # Build NIP-42 auth event
+    auth_created = int(time.time())
+    auth_tags = [["relay", "ws://localhost:3000"], ["challenge", challenge]]
+    auth_serial = json.dumps([0, pubkey_hex, auth_created, 22242, auth_tags, ""], separators=(',',':'))
+    auth_id = hashlib.sha256(auth_serial.encode()).digest()
+    auth_sig = sign_schnorr(privkey, auth_id)
+    auth_event = {
+        "id": auth_id.hex(),
+        "pubkey": pubkey_hex,
+        "created_at": auth_created,
+        "kind": 22242,
+        "tags": auth_tags,
+        "content": "",
+        "sig": auth_sig.hex()
+    }
+    ws.send(json.dumps(["AUTH", auth_event]))
+    resp = json.loads(ws.recv())
+    if resp[0] != "OK" or not resp[2]:
+        print(f"AUTH failed: {resp}")
+        ws.close()
+        exit(1)
+
+# Now send the actual event
+ws.send(json.dumps(["EVENT", event]))
+resp = json.loads(ws.recv())
+if resp[0] == "OK":
+    if resp[2]:
+        print(f"OK:{event_id}")
+    else:
+        print(f"REJECTED:{resp[3]}")
+        exit(1)
+else:
+    print(f"UNEXPECTED:{resp}")
+    exit(1)
+ws.close()
+PYEOF
+}
+
+# ── Helper: configure git for a keypair ───────────────────────────────────────
+
+setup_git_clone() {
+    local clone_dir="$1"
+    local privkey="$2"
+    local pubkey="$3"
+
+    local cred_helper="${REPO_ROOT}/target/release/git-credential-nostr"
+
+    # Configure git to use our credential helper
+    git -C "$clone_dir" config credential.helper ""
+    git -C "$clone_dir" config credential.useHttpPath true
+    git -C "$clone_dir" config "credential.http://localhost:3000.helper" "$cred_helper"
+
+    # Set the private key env var for the credential helper
+    export NOSTR_PRIVATE_KEY="$privkey"
+}
+
+# ── Test: Create channel and repo ─────────────────────────────────────────────
+
+log "Creating channel..."
+
+CHANNEL_ID=$(python3 -c "import uuid; print(str(uuid.uuid4()))")
+log "  Channel ID: $CHANNEL_ID"
+
+# Create channel (kind:9000 with specific tags)
+CHANNEL_RESULT=$(send_event "$OWNER_PRIVKEY" 9000 "" "[\"h\", \"$CHANNEL_ID\"], [\"name\", \"e2e-git-test\"], [\"type\", \"channel\"], [\"action\", \"create\"]")
+echo "  Channel create: $CHANNEL_RESULT"
+
+# Add bot1 as member
+log "Adding bot1 to channel..."
+ADD_BOT1=$(send_event "$OWNER_PRIVKEY" 9000 "" "[\"h\", \"$CHANNEL_ID\"], [\"p\", \"$BOT1_PUBKEY\"], [\"role\", \"member\"], [\"action\", \"add_member\"]")
+echo "  Add bot1: $ADD_BOT1"
+
+# Add bot2 as member
+log "Adding bot2 to channel..."
+ADD_BOT2=$(send_event "$OWNER_PRIVKEY" 9000 "" "[\"h\", \"$CHANNEL_ID\"], [\"p\", \"$BOT2_PUBKEY\"], [\"role\", \"bot\"], [\"action\", \"add_member\"]")
+echo "  Add bot2 (as bot role): $ADD_BOT2"
+
+# Create repo (kind:30617)
+REPO_NAME="e2e-webpage"
+log "Creating repo: $REPO_NAME..."
+CREATE_REPO=$(send_event "$OWNER_PRIVKEY" 30617 "" "[\"d\", \"$REPO_NAME\"], [\"sprout-channel\", \"$CHANNEL_ID\"]")
+echo "  Create repo: $CREATE_REPO"
+
+# Wait for side effect (repo creation on disk)
+sleep 2
+
+# Verify repo exists
+if [[ -d "${REPO_ROOT}/repos/${OWNER_PUBKEY}/${REPO_NAME}.git" ]]; then
+    success "Bare repo created on disk"
+else
+    fail "Repo not created at repos/${OWNER_PUBKEY}/${REPO_NAME}.git"
+fi
+
+# Verify hook installed
+if [[ -x "${REPO_ROOT}/repos/${OWNER_PUBKEY}/${REPO_NAME}.git/hooks/pre-receive" ]]; then
+    success "Pre-receive hook installed and executable"
+else
+    fail "Pre-receive hook not found or not executable"
+fi
+
+# ── Test: Bot1 clones and pushes index.html ───────────────────────────────────
+
+log "Bot1: cloning repo..."
+BOT1_DIR="$WORK_DIR/bot1"
+mkdir -p "$BOT1_DIR"
+
+export NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY"
+export GIT_TERMINAL_PROMPT=0
+
+# Clone (empty repo)
+git clone \
+    -c credential.helper="" \
+    -c credential.useHttpPath=true \
+    -c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
+    "http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}" \
+    "$BOT1_DIR/repo" 2>&1 || true
+
+# If clone failed (empty repo), init manually
+if [[ ! -d "$BOT1_DIR/repo/.git" ]]; then
+    mkdir -p "$BOT1_DIR/repo"
+    git -C "$BOT1_DIR/repo" init
+    git -C "$BOT1_DIR/repo" remote add origin "http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}"
+    git -C "$BOT1_DIR/repo" config credential.helper ""
+    git -C "$BOT1_DIR/repo" config credential.useHttpPath true
+    git -C "$BOT1_DIR/repo" config "credential.http://localhost:3000.helper" "${REPO_ROOT}/target/release/git-credential-nostr"
+fi
+
+# Create index.html
+cat > "$BOT1_DIR/repo/index.html" << 'HTML'
+<!DOCTYPE html>
+<html>
+<head>
+    <title>Sprout E2E Test Page</title>
+    <style>
+        body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 2rem; }
+        h1 { color: #2d5016; }
+        .contributor { padding: 0.5rem; margin: 0.5rem 0; background: #f0f9e8; border-radius: 4px; }
+    </style>
+</head>
+<body>
+    <h1>🌱 Sprout Collaborative Page</h1>
+    <p>This page was created by two bots collaborating via Sprout's git server.</p>
+    <div class="contributor">
+        <strong>Bot 1</strong> — Created the initial page structure
+    </div>
+</body>
+</html>
+HTML
+
+git -C "$BOT1_DIR/repo" add -A
+git -C "$BOT1_DIR/repo" -c user.name="Bot1" -c user.email="bot1@sprout.test" commit -m "Initial page structure"
+
+log "Bot1: pushing..."
+export NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY"
+if git -C "$BOT1_DIR/repo" \
+    -c credential.helper="" \
+    -c credential.useHttpPath=true \
+    -c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
+    push -u origin main 2>&1; then
+    success "Bot1 push succeeded (member can push)"
+else
+    # Check relay log for clues
+    tail -20 /tmp/sprout-relay-e2e.log
+    fail "Bot1 push failed (member should be able to push)"
+fi
+
+# ── Test: Bot2 (bot role) clones and pushes ───────────────────────────────────
+
+log "Bot2: cloning repo..."
+BOT2_DIR="$WORK_DIR/bot2"
+
+export NOSTR_PRIVATE_KEY="$BOT2_PRIVKEY"
+git clone \
+    -c credential.helper="" \
+    -c credential.useHttpPath=true \
+    -c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
+    "http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}" \
+    "$BOT2_DIR" 2>&1
+
+# Modify index.html
+cat >> "$BOT2_DIR/index.html" << 'HTML'
+    <div class="contributor">
+        <strong>Bot 2</strong> — Added this section (pushing as bot role → promoted to member)
+    </div>
+    <footer>
+        <p><em>Built with Sprout sovereign git hosting</em></p>
+    </footer>
+HTML
+
+git -C "$BOT2_DIR" add -A
+git -C "$BOT2_DIR" -c user.name="Bot2" -c user.email="bot2@sprout.test" commit -m "Add bot2 section and footer"
+
+log "Bot2: pushing (bot role, should be promoted to member)..."
+export NOSTR_PRIVATE_KEY="$BOT2_PRIVKEY"
+if git -C "$BOT2_DIR" \
+    -c credential.helper="" \
+    -c credential.useHttpPath=true \
+    -c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
+    push 2>&1; then
+    success "Bot2 push succeeded (bot promoted to member)"
+else
+    tail -20 /tmp/sprout-relay-e2e.log
+    fail "Bot2 push failed (bot should be promoted to member)"
+fi
+
+# ── Test: Non-member push denied ──────────────────────────────────────────────
+
+log "Guest: attempting push (should be denied)..."
+GUEST_DIR="$WORK_DIR/guest"
+
+export NOSTR_PRIVATE_KEY="$GUEST_PRIVKEY"
+git clone \
+    -c credential.helper="" \
+    -c credential.useHttpPath=true \
+    -c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
+    "http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}" \
+    "$GUEST_DIR" 2>&1
+
+echo "<!-- unauthorized -->" >> "$GUEST_DIR/index.html"
+git -C "$GUEST_DIR" add -A
+git -C "$GUEST_DIR" -c user.name="Guest" -c user.email="guest@evil.test" commit -m "Unauthorized change"
+
+export NOSTR_PRIVATE_KEY="$GUEST_PRIVKEY"
+if git -C "$GUEST_DIR" \
+    -c credential.helper="" \
+    -c credential.useHttpPath=true \
+    -c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
+    push 2>&1; then
+    fail "Guest push succeeded (should have been denied!)"
+else
+    success "Guest push denied (not a channel member)"
+fi
+
+# ── Final verification ────────────────────────────────────────────────────────
+
+log "Verifying final repo state..."
+VERIFY_DIR="$WORK_DIR/verify"
+
+export NOSTR_PRIVATE_KEY="$OWNER_PRIVKEY"
+git clone \
+    -c credential.helper="" \
+    -c credential.useHttpPath=true \
+    -c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
+    "http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}" \
+    "$VERIFY_DIR" 2>&1
+
+if grep -q "Bot 1" "$VERIFY_DIR/index.html" && grep -q "Bot 2" "$VERIFY_DIR/index.html"; then
+    success "Final repo contains both bots' contributions"
+else
+    fail "Final repo missing expected content"
+fi
+
+log "Commit log:"
+git -C "$VERIFY_DIR" log --oneline
+
+echo ""
+echo -e "${GREEN}════════════════════════════════════════════════════════${NC}"
+echo -e "${GREEN}  All E2E git permission tests passed!${NC}"
+echo -e "${GREEN}════════════════════════════════════════════════════════${NC}"
+echo ""
+echo "Final page content:"
+echo "─────────────────────"
+cat "$VERIFY_DIR/index.html"
