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.
This commit is contained in:
parent
b3d002e4a7
commit
867be7bb98
2 changed files with 192 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 — 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue