diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index e4ddebc0..6bbdeb60 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -59,6 +59,11 @@ pub enum SocketReply { hive_name: Option, swarm_name: Option, }, + /// `create_repo` result — the new repo's full name + clone URL. + RepoCreated { + full_name: String, + clone_url: String, + }, } impl From for SocketReply { @@ -100,6 +105,13 @@ impl From for SocketReply { hive_name, swarm_name, }, + hive_sh4re::Response::RepoCreated { + full_name, + clone_url, + } => Self::RepoCreated { + full_name, + clone_url, + }, } } } @@ -910,6 +922,35 @@ impl AgentServer { .await } + #[tool( + description = "Create a git repo through hive-c0re. You CANNOT create repos with your \ + own forge token (creation is disabled) — this is the only path. The repo is created in \ + the c0re-owned `agents` org, you're added as a write collaborator (not owner), and the \ + default branch gets branch protection so merges require an operator-team approval — you \ + cannot merge your own PRs. `repo` is a single name segment (letters, digits, `-`, `_`, \ + `.`). Returns the new repo's full name + clone URL; clone it over \ + `http://localhost:3000/agents/.git` and push/open PRs as normal." + )] + async fn create_repo(&self, Parameters(args): Parameters) -> String { + let log = format!("{args:?}"); + run_tool_envelope("create_repo", log, async move { + let (resp, retries) = self + .dispatch(hive_sh4re::Request::CreateRepo { repo: args.repo }) + .await; + let s = match resp { + Ok(SocketReply::RepoCreated { + full_name, + clone_url, + }) => format!("created repo {full_name} — clone: {clone_url}"), + Ok(SocketReply::Err(m)) => format!("create_repo failed: {m}"), + Ok(other) => format!("create_repo unexpected response: {other:?}"), + Err(e) => format!("create_repo transport error: {e:#}"), + }; + annotate_retries(s, retries) + }) + .await + } + #[tool( description = "Schedule a reminder that lands in this agent's own inbox at a future \ time (sender will appear as `reminder`). Use for self-paced follow-ups: 'check task \ @@ -1505,6 +1546,13 @@ pub struct SetStatusArgs { pub text: String, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct CreateRepoArgs { + /// Repo name — a single segment of letters, digits, `-`, `_`, `.` + /// (no leading `-`/`.`). The repo is created as `agents/`. + pub repo: String, +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct GetAgentMetaArgs { /// Logical name of the agent to query (e.g. `"iris"`, `"manager"`). diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 4b61bedb..66002c6d 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -183,6 +183,7 @@ pub(crate) async fn dispatch_shared( |()| hive_sh4re::Response::Ok, ) } + hive_sh4re::Request::CreateRepo { repo } => handle_create_repo(agent, repo).await, hive_sh4re::Request::AckTurn => handle_ack_turn(coord, agent), hive_sh4re::Request::RequeueInflight => handle_requeue_inflight(coord, agent), hive_sh4re::Request::GracefulStopComplete => { @@ -304,6 +305,46 @@ fn handle_set_status(coord: &Arc, text: &str) -> hive_sh4re::Respon hive_sh4re::Response::Ok } +/// Validate an agent-supplied repo name: a single safe slug segment, no +/// path traversal. Forgejo validates server-side too, but rejecting early +/// gives a clear message and avoids building odd API paths. +fn valid_repo_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 100 + && !name.starts_with(['-', '.']) + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} + +/// `CreateRepo` — create a repo for `agent` *through hive-c0re* in the +/// c0re-owned `agents` org with operator-team branch protection. +/// The sanctioned create path now that agents can't create repos directly. +async fn handle_create_repo(agent: &str, repo: &str) -> hive_sh4re::Response { + if !valid_repo_name(repo) { + return hive_sh4re::Response::Err { + message: format!( + "invalid repo name {repo:?} — single segment of letters, digits, '-', '_', '.' \ + (no leading '-'/'.', max 100 chars)" + ), + }; + } + let Some(core_token) = crate::forge::core_token() else { + return hive_sh4re::Response::Err { + message: "forge unavailable (no core token) — cannot create repo".to_owned(), + }; + }; + match crate::forge::create_agent_repo(agent, repo, &core_token).await { + Ok(full_name) => hive_sh4re::Response::RepoCreated { + clone_url: format!("{}/{full_name}.git", crate::forge::FORGE_HTTP), + full_name, + }, + Err(e) => hive_sh4re::Response::Err { + message: format!("create repo {repo:?} failed: {e:#}"), + }, + } +} + /// `GetAgentMeta` — identity + live status for `name` (defaults to the /// caller). Reads the live container-view status and the hive/swarm /// display names. diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 9449700d..d0f4f8eb 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. 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. `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. 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 — `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. 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: 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 +/// 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(format!("{AGENTS_ORG}/{repo}")) +} + /// 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: 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 + //. 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..df63d19c 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). 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 diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 8cdb9f47..b49f1963 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -572,6 +572,13 @@ pub enum Request { /// per-kind semantics in /// `docs/conventions.md::Loose-ends wire shape`. CancelLooseEnd { kind: CancelLooseEndKind, id: i64 }, + /// Create a git repo *through hive-c0re*. Agents can't create + /// repos with their own forge token (`max_repo_creation = 0`); this is + /// the sanctioned path. hive-c0re creates `repo` in the c0re-owned + /// `agents` org, adds the calling agent as a write collaborator (not + /// owner), and applies operator-team branch protection so the author + /// can't merge its own PRs. Returns the new repo's full name. + CreateRepo { repo: String }, /// Mark every message popped since the last `AckTurn` as handled. /// Harness↔broker pairing fired after `TurnOutcome::Ok`. See /// `docs/conventions.md::Broker delivery + ack cycle`. @@ -753,6 +760,12 @@ pub enum Response { /// `ListSchedules` result. Snapshot of every schedule. /// Returned on the manager socket only. Schedules { schedules: Vec }, + /// `CreateRepo` result: the new repo's full name (`agents/`) + /// and clone URL, so the agent can immediately `git clone` it. + RepoCreated { + full_name: String, + clone_url: String, + }, /// `ListDescendants` result: all descendant containers, with running /// status. Ordered by topology depth (parents before children), then /// alphabetically within each depth tier. @@ -952,6 +965,10 @@ pub enum ToolGroup { Scheduling, /// `get_logs` - *(privileged)* Diagnostics, + /// `create_repo` — create git repos through hive-c0re (the only path + /// now that agents can't create them directly). Opt-in per + /// agent so the operator controls who can spin up repos. + Forge, /// `run`, `status` (via `mcp__bash__*`) Execution, /// Claude built-in web egress tools: `WebFetch` (retrieve a URL) and @@ -991,6 +1008,7 @@ impl ToolGroup { "list_schedules", ], Self::Diagnostics => &["get_logs"], + Self::Forge => &["create_repo"], Self::Execution => &["run", "status"], Self::WebTools => &[], } @@ -1044,6 +1062,7 @@ impl ToolGroup { Self::Approvals, Self::Scheduling, Self::Diagnostics, + Self::Forge, Self::Execution, Self::WebTools, ]; @@ -1060,6 +1079,7 @@ impl ToolGroup { Self::Approvals => "approvals", Self::Scheduling => "scheduling", Self::Diagnostics => "diagnostics", + Self::Forge => "forge", Self::Execution => "execution", Self::WebTools => "web_tools", } @@ -1088,6 +1108,9 @@ impl ToolGroup { Self::Diagnostics => { "get_logs — read a sub-agent container's systemd journal (privileged)" } + Self::Forge => { + "create_repo — create git repos through hive-c0re (operator-gated merge)" + } Self::Execution => { "run, status — run shell commands via mcp__bash__run / mcp__bash__status" }