Compare commits

...
Author SHA1 Message Date
atlas
27ac0153c4 forge authz: scrub tracker tags from comments
Replace the #-number tracker references in code comments with prose
(tracker-tag lint; hive convention is prose in source). No behaviour
change. Branch-protection / collaborator / team / repo API field names
were verified against the live Forgejo swagger.
2026-06-21 22:36:45 +02:00
atlas
f1d54ce12c agent surface: create_repo through hive-c0re (#1787)
Closes the #1787 loop — the sanctioned create path now that agents
can't create repos directly. Adds:

- wire: Request::CreateRepo{repo} + Response::RepoCreated{full_name,
  clone_url} (hive-sh4re).
- agent_server: dispatch_shared arm + handle_create_repo — validates the
  repo name, then forge::create_agent_repo (org-owned repo, agent=write
  collaborator, operator-team branch protection). Returns the full name
  + clone url so the agent can git clone immediately.
- MCP: create_repo tool + CreateRepoArgs in the harness.
- a new opt-in ToolGroup::Forge (=[create_repo]) so the operator
  controls which agents can spin up repos (least privilege).

Workspace clippy -D warnings, cargo test, nix fmt all green.
2026-06-21 22:36:45 +02:00
atlas
867be7bb98 hive-c0re: block direct agent repo creation, add c0re-mediated create (#1787)
Agents must no longer create repos with their own forge token (a
write-scoped token otherwise creates + owns repos and can self-merge,
bypassing operator-only-merge). Instead:

- Set max_repo_creation=0 on every agent forge user (marker-guarded
  PATCH in sync_agent; covers agents provisioned before this). Blocks
  new direct creation; push/PR/clone and existing repos are untouched.
- Provision a c0re-owned 'agents' org (the namespace agent repos land
  in) plus an empty 'operators' team inside it. The org owns the repos
  so perms stay c0re-managed; the operator joins the team out-of-band.
- create_agent_repo() is the sanctioned path: creates the repo in the
  agents org, adds the requesting agent as a *write* collaborator (not
  owner), and applies branch protection that whitelists merge + required
  approval to the operators team — so the author can't merge its own PR.
- is_hive_managed_namespace() guards 'internal'/'agent-configs'/'core'
  against a future create surface passing an explicit owner.

No existing repos are modified. The agent/hivectl surface that invokes
create_agent_repo is a follow-up commit.
2026-06-21 22:36:45 +02:00
5 changed files with 304 additions and 1 deletions

View file

@ -59,6 +59,11 @@ pub enum SocketReply {
hive_name: Option<String>,
swarm_name: Option<String>,
},
/// `create_repo` result — the new repo's full name + clone URL.
RepoCreated {
full_name: String,
clone_url: String,
},
}
impl From<hive_sh4re::Response> for SocketReply {
@ -100,6 +105,13 @@ impl From<hive_sh4re::Response> 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/<repo>.git` and push/open PRs as normal."
)]
async fn create_repo(&self, Parameters(args): Parameters<CreateRepoArgs>) -> 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/<repo>`.
pub repo: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GetAgentMetaArgs {
/// Logical name of the agent to query (e.g. `"iris"`, `"manager"`).

View file

@ -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<Coordinator>, 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.

View file

@ -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<String> {
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.

View file

@ -60,6 +60,16 @@ pub fn forge_email_aligned_marker(name: &str) -> PathBuf {
forge_dir().join(format!("email-aligned-{name}"))
}
/// `forge/repo-creation-disabled-<name>` — marker: `<name>`'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

View file

@ -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<WireSchedule> },
/// `CreateRepo` result: the new repo's full name (`agents/<repo>`)
/// 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"
}