diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 9449700d..4a6fba4d 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -51,9 +51,30 @@ const SHARED_DOCS_REPO: &str = "docs"; /// Bind-mounted read-only into every container at `/knowledge`. /// See `hive-c0re/src/knowledge.rs`. const KNOWLEDGE_REPO: &str = crate::knowledge::REPO; +/// Forgejo org that owns agent-created repos (#1787). Agents can't create +/// repos with their own token (`max_repo_creation = 0`); instead hive-c0re +/// creates them here and adds the requesting agent as a **write** member +/// (not owner/admin). Because the org — not the agent — owns the repo, +/// perms stay c0re-managed and branch protection (referencing +/// [`OPERATORS_TEAM`]) can block the author from merging their own PR. This +/// is the "agents namespace" repos land in by default. +const AGENTS_ORG: &str = "agents"; +/// Operator merge-gate team inside [`AGENTS_ORG`]. Provisioned **empty** by +/// hive-c0re (so perms can be set before anyone joins); the operator adds +/// herself via the forge UI / hivectl. Branch protection on agents-org repos +/// references this team by name for the merge/approval whitelist, so the +/// rule never hardcodes a specific reviewer agent (which may not exist). +const OPERATORS_TEAM: &str = "operators"; +/// Hive-managed Forgejo namespaces that agent-initiated repo creation must +/// never target (#1787). `internal` is operator-curated shared content; +/// `agent-configs` + `core` are hive-c0re-internal mirror/meta namespaces. +/// (`hyperhive` is NOT managed — it's just a repo that happens to be built +/// by this hive.) hive-c0re's create path forces [`AGENTS_ORG`], so this is +/// a defensive guard against any future caller passing an explicit owner. +const HIVE_MANAGED_NAMESPACES: &[&str] = &[SHARED_ORG, CONFIG_ORG, "core"]; /// Forgejo orgs hive-c0re ensures on startup. The meta repo lives at /// `core/meta` (the `core` user's own namespace — no org needed). -const SEEDED_ORGS: &[&str] = &[CONFIG_ORG, SHARED_ORG]; +const SEEDED_ORGS: &[&str] = &[CONFIG_ORG, SHARED_ORG, AGENTS_ORG]; /// Per-agent token scopes (broad-but-not-admin). See /// `docs/forge.md::Token scopes` for the per-scope rationale. const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc"; @@ -249,6 +270,57 @@ async fn ensure_user_email(name: &str) { } } +/// Disable direct repo creation for agent `name` by setting +/// `max_repo_creation = 0` on its Forgejo account (#1787). Agents must +/// create repos *through hive-c0re* (which owns the perms), never with +/// their own token — a write-scoped token can otherwise create + own +/// repos and self-merge, bypassing the operator-only-merge policy. +/// +/// `max_repo_creation = 0` means `CanCreateRepo()` is false for any +/// count (Forgejo: `MaxRepoCreation >= 0 && NumRepos >= MaxRepoCreation`), +/// so creation is refused while push / PR / clone stay intact. **Existing +/// repos are untouched** — this only blocks *new* direct creation. +/// +/// Marker-guarded like [`ensure_user_email`]: the PATCH runs once per +/// agent (delete the marker to re-apply). Body carries `login_name` + +/// `source_id` for the same reason `ensure_user_email` does — omitting +/// `login_name` makes Forgejo's `EditUserOption` validator reset +/// `use_custom_avatar`. Best-effort: failures warn, don't propagate. +async fn ensure_repo_creation_disabled(name: &str) { + let marker = crate::paths::forge_repo_creation_disabled_marker(name); + if marker.exists() { + return; + } + let Some(token) = core_token() else { + tracing::debug!(%name, "forge: skipping ensure_repo_creation_disabled — no core token yet"); + return; + }; + let body = format!(r#"{{"login_name":"{name}","source_id":0,"max_repo_creation":0}}"#); + let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}"); + match forge_http(reqwest::Method::PATCH, &url, &token, &body).await { + Ok(status) if status.is_success() => { + if let Some(parent) = marker.parent() { + std::fs::create_dir_all(parent).ok(); + } + std::fs::write(&marker, "").ok(); + tracing::info!(%name, "forge: disabled direct repo creation (max_repo_creation=0)"); + } + Ok(status) if status == reqwest::StatusCode::FORBIDDEN => { + tracing::warn!( + %name, %status, + "forge: PATCH max_repo_creation forbidden — core token likely missing admin scope. \ + Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes." + ); + } + Ok(status) => { + tracing::warn!(%name, %status, "forge: PATCH max_repo_creation returned non-success"); + } + Err(e) => { + tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation transport error") + } + } +} + /// Mint a fresh access token for `name`. Token name is suffixed with /// a monotonic clock so re-issuing doesn't collide with an existing /// token of the same name in the DB. `scopes` is the scope string @@ -821,6 +893,105 @@ async fn ensure_org(name: &str, admin_token: &str) -> Result<()> { } } +/// Whether `ns` is a hive-managed Forgejo namespace that agent-initiated +/// repo creation must never target (#1787) — `internal` (operator-curated +/// shared content) + `agent-configs` / `core` (hive-c0re-internal). The +/// create path forces [`AGENTS_ORG`], so this guards a future surface that +/// might accept an explicit owner. +#[must_use] +pub fn is_hive_managed_namespace(ns: &str) -> bool { + HIVE_MANAGED_NAMESPACES.contains(&ns) +} + +/// Provision the [`OPERATORS_TEAM`] inside [`AGENTS_ORG`] as an **empty** +/// team (#1787). Branch protection on agents-org repos references it as the +/// merge/approval whitelist; the operator adds herself as a member via the +/// forge UI / hivectl. `includes_all_repositories` so the gate applies to +/// every agent repo; `write` is enough to approve + merge. hive-c0re never +/// manages membership. Idempotent (422/409 = already exists). +async fn ensure_operators_team(token: &str) -> Result<()> { + let url = format!("{FORGE_HTTP}/api/v1/orgs/{AGENTS_ORG}/teams"); + let body = format!( + r#"{{"name":"{OPERATORS_TEAM}","description":"hyperhive operators — merge gate for agent repos","permission":"write","includes_all_repositories":true,"can_create_org_repo":false}}"# + ); + let status = forge_http(reqwest::Method::POST, &url, token, &body).await?; + match status.as_u16() { + 201 => { + tracing::info!("forge: created {OPERATORS_TEAM} team in {AGENTS_ORG}"); + Ok(()) + } + 409 | 422 => { + tracing::debug!("forge: {OPERATORS_TEAM} team already exists"); + Ok(()) + } + other => { + anyhow::bail!("POST /orgs/{AGENTS_ORG}/teams ({OPERATORS_TEAM}) returned HTTP {other}") + } + } +} + +/// Add `user` as a collaborator on `owner/repo` at `permission` +/// (`read` / `write` / `admin`). Idempotent: 201 (added) and 204 (already a +/// collaborator / permission updated) both count as success. +async fn add_collaborator( + owner: &str, + repo: &str, + user: &str, + permission: &str, + token: &str, +) -> Result<()> { + let url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}/collaborators/{user}"); + let body = format!(r#"{{"permission":"{permission}"}}"#); + let status = forge_http(reqwest::Method::PUT, &url, token, &body).await?; + match status.as_u16() { + 201 | 204 => { + tracing::debug!(%owner, %repo, %user, %permission, "forge: collaborator set"); + Ok(()) + } + other => { + anyhow::bail!("PUT {owner}/{repo}/collaborators/{user} returned HTTP {other}") + } + } +} + +/// Apply the operator merge-gate branch protection to `repo`'s default +/// branch (#1787): only [`OPERATORS_TEAM`] members can merge, and an +/// approving review from that team is required — so the author (a write-level +/// agent, not in the team) cannot merge its own PR. Idempotent: an existing +/// rule for the branch (200/409/422) is treated as success. +async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> { + let url = format!("{FORGE_HTTP}/api/v1/repos/{AGENTS_ORG}/{repo}/branch_protections"); + let body = format!( + r#"{{"branch_name":"main","enable_merge_whitelist":true,"merge_whitelist_teams":["{OPERATORS_TEAM}"],"enable_approvals_whitelist":true,"approvals_whitelist_teams":["{OPERATORS_TEAM}"],"required_approvals":1,"block_on_official_review_requests":true}}"# + ); + let status = forge_http(reqwest::Method::POST, &url, token, &body).await?; + match status.as_u16() { + 201 => { + tracing::info!(%repo, "forge: applied operator branch protection"); + Ok(()) + } + 200 | 409 | 422 => { + tracing::debug!(%repo, "forge: branch protection already present"); + Ok(()) + } + other => anyhow::bail!("POST {AGENTS_ORG}/{repo}/branch_protections returned HTTP {other}"), + } +} + +/// Create a repo for `agent` in the c0re-owned [`AGENTS_ORG`] and wire the +/// #1787 perms: the org owns it (perms stay c0re-managed), the agent is added +/// as a **write** collaborator (not owner — can push + open PRs but can't +/// bypass branch protection), and the default branch gets the operator +/// merge gate. This is the sanctioned create path now that agents can't +/// create repos directly (`max_repo_creation = 0`). Idempotent. +pub async fn create_agent_repo(agent: &str, repo: &str, core_token: &str) -> Result<()> { + ensure_org_repo(AGENTS_ORG, repo, core_token).await?; + add_collaborator(AGENTS_ORG, repo, agent, "write", core_token).await?; + apply_operator_branch_protection(repo, core_token).await?; + tracing::info!(%agent, %repo, "forge: created agent repo in {AGENTS_ORG} with operator merge gate"); + Ok(()) +} + /// Per-agent forge sync: ensure the agent has a forgejo user + token, /// a mirrored config repo, read access to `core/meta`, and the `meta` /// remote in its proposed repo. All operations are idempotent; failures @@ -840,6 +1011,10 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) { // so commits link to the agent's Forgejo profile. Best-effort; // also patches up agents created before this fix (old @hive.local). ensure_user_email(name).await; + // Block direct agent-initiated repo creation (#1787): agents create + // repos through hive-c0re, never with their own token. Idempotent + + // marker-guarded; also covers agents provisioned before this landed. + ensure_repo_creation_disabled(name).await; // Mirror the agent's applied config repo into agent-configs. // ensure_config_repo is idempotent; push_config catches any // drift since the last run — e.g. the startup migration just @@ -896,6 +1071,12 @@ pub async fn ensure_all() { tracing::warn!(%org, error = ?e, "forge: ensure_org failed"); } } + // Provision the operator merge-gate team (empty) inside the agents + // org so branch protection can reference it before anyone joins + // (#1787). The operator adds herself as a member out-of-band. + if let Err(e) = ensure_operators_team(token).await { + tracing::warn!(error = ?e, "forge: ensure_operators_team failed"); + } // Meta repo lives at core/meta — pushed from git_commit in // meta.rs on every deploy/lock-update. Make sure it exists // before the first push hits a 404. diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 3ffcf974..72887b7f 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -60,6 +60,16 @@ pub fn forge_email_aligned_marker(name: &str) -> PathBuf { forge_dir().join(format!("email-aligned-{name}")) } +/// `forge/repo-creation-disabled-` — marker: ``'s forge user +/// has had `max_repo_creation = 0` applied (blocks direct agent-initiated +/// repo creation — see #1787). One-shot guard so the PATCH runs once per +/// agent (including agents provisioned before the change); delete to +/// re-apply. +#[must_use] +pub fn forge_repo_creation_disabled_marker(name: &str) -> PathBuf { + forge_dir().join(format!("repo-creation-disabled-{name}")) +} + /// `matrix/` — host-side matrix provisioning state (admin token, hive /// Space room id, per-agent password creds). The shared registration /// token is bind-mounted into the tuwunel container via nix and stays