fix(phase6): allow-list scopes WRITES only; reads unrestricted

The previous policy denied any path not in a short allow-list of
read-only surfaces (/user, /user/orgs, /search, /rate_limit, /meta,
/markdown, plus /repos/<allow-listed>/...). That broke common agent
flows: `gh repo view octocat/Hello-World`, `git clone
https://github.com/<any>/<any>`, `gh issue list <any>/<any>` all
403'd.

Re-spec to what the user actually wants: read-only access is
unrestricted (the agent often needs to browse other repos —
look up an API, fetch a README, check who reviewed a PR); only
write / management ops are scoped to the per-launch allow-list.

**`github_path_allowed(method, path, allowed)`** (api.github.com):
- `GET` / `HEAD` → allow (read).
- `POST /graphql` → allow (no body-level mutation filter in v1;
  documented gap).
- `POST /markdown`, `POST /user/repos` → allow (utility / creates a
  new repo owned by the user).
- `POST/PATCH/PUT/DELETE` under `/repos/<owner>/<repo>/*` → allowed
  iff `<owner>/<repo>` in the allow-list. Catches PR creation, issue
  creation, merges, comments, deletes, branch protection edits,
  releases.
- Anything else (e.g. `POST /user/keys`, `DELETE /orgs/<x>/...`) →
  deny. These aren't routine gh CLI flows.
- `..` path segments still rejected up front (GitHub server-side
  normalisation would otherwise resolve to a different repo).

**`github_smart_decision(request, allowed)`** (github.com / codeload
/ raw / objects smart-HTTP):
- `git-receive-pack` POST (push data) → owner/repo must be in allow-
  list.
- `info/refs?service=git-receive-pack` GET (push handshake) → same.
- Everything else (`git-upload-pack`, codeload archives, raw blobs,
  any other path) → passthrough. clone, fetch, browse all work
  against any repo.

Verified inside the guest:
- `gh api repos/octocat/Hello-World` (not allow-listed) — reaches
  GitHub (no 403).
- `git ls-remote https://github.com/octocat/Hello-World` — real refs
  returned via streaming passthrough.
- `gh api -X POST repos/octocat/Hello-World/issues` — clean 403 from
  our hook naming the read/write distinction and the allow-list.
- `gh api -X POST repos/wirenboard/agent-vm/issues` (allow-listed) —
  reaches GitHub.
- `gh api graphql -f query=...` — reaches GitHub.

6/6 cargo tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Evgeny Boger 2026-05-24 15:31:25 +03:00
parent 546d24750f
commit 3a11466d84

View file

@ -155,11 +155,11 @@ async fn forward_github_api(
let (method, path, headers, body) = parse_http_request(request)
.context("parsing intercepted github request")?;
if !github_path_allowed(&path, allowed_repos) {
if !github_path_allowed(&method, &path, allowed_repos) {
return Ok(error_response(
403,
&format!(
"agent-vm: path {path:?} blocked by per-launch repo allow-list. Allowed repos: {}",
"agent-vm: {method} {path:?} blocked by per-launch repo allow-list (read access is unrestricted; writes/mutations are scoped). Allowed repos: {}",
if allowed_repos.is_empty() {
"(none — pass --repo OWNER/NAME or run inside a project with a github remote)".into()
} else {
@ -297,65 +297,68 @@ fn parse_http_request(req: &[u8]) -> Result<(String, String, Vec<(String, String
Ok((method, path, headers, body))
}
/// Path-based allow-list check for api.github.com requests.
/// Method+path-based allow-list check for api.github.com requests.
///
/// Accepts (case-insensitive on owner/repo):
/// - `/repos/<owner>/<repo>[/...]` where `<owner>/<repo>` is in the
/// allow-list, after rejecting any `..` traversal segment so a
/// crafted path like `/repos/<allowed>/<repo>/../../<victim>/<private>`
/// can't pass the surface check and let GitHub resolve `..`
/// upstream.
/// - `/user` and `/user/...` — gh CLI auth-status, current-user info.
/// Limited to the small set gh actually needs: /user (auth probe),
/// /user/orgs (org membership). Excludes /user/repos, /user/keys,
/// /user/emails, /user/gpg_keys to avoid leaking full inventory /
/// PII.
/// - **`/graphql`** — gh CLI's `gh pr create`, `gh issue create`,
/// `gh repo view --json` all use GraphQL mutations. We DO NOT body-
/// filter the GraphQL request: per-repo scoping doesn't reach into
/// the JSON body in v1. Accept that an agent that crafts arbitrary
/// GraphQL queries can read anything the token can; the practical
/// trade-off is "gh CLI works" vs. "no GraphQL".
/// - `/search/...`, `/rate_limit`, `/meta`, `/markdown` — small
/// utility endpoints gh CLI uses ambiently.
/// - `/orgs/<org>` (no further path) — gh CLI reads org metadata to
/// resolve `gh repo view org/...`.
fn github_path_allowed(path: &str, allowed: &[String]) -> bool {
/// **Policy:** read-only access is unrestricted (agents commonly need
/// to read arbitrary public/private GitHub state — browse a different
/// repo, look up an API, fetch a README, check who reviewed a PR).
/// Only **write / management** operations are scoped to the per-launch
/// allow-list, mirroring the user's actual concern: "don't let the
/// agent push to / mutate repos I didn't list."
///
/// Allowed unconditionally:
/// - Any `GET` or `HEAD` request (read-only).
/// - `POST /graphql` (gh PR/issue/etc. creation uses this). We do
/// **not** body-filter GraphQL mutations — that needs the request
/// body which the streaming intercept path doesn't see. Documented
/// gap: an agent that crafts a GraphQL mutation can do anything the
/// token can.
/// - `POST /markdown` (utility, no state change).
/// - `POST /user/repos`, `POST /repos/<owner>/<repo>/forks`,
/// `POST /repos/<owner>/<repo>/transfer` and similar repo-creation
/// shapes: these create new repos owned by the user — not "mutating
/// someone else's repo." Allowed.
///
/// Write methods on `/repos/<owner>/<repo>/*`:
/// - Allowed only when `<owner>/<repo>` (case-insensitive) is in
/// the allow-list. Catches PR creation, issue creation, comments,
/// merges, branch protection edits, releases, deletes — anything
/// that mutates a specific repo's state.
///
/// Traversal: any `..` path segment is rejected up front so a
/// crafted `/repos/<allowed>/<repo>/../../<victim>/<private>` can't
/// pass the prefix check and let GitHub resolve `..` upstream.
///
/// Anything else (write methods to paths outside /repos/ that aren't
/// in the explicit allow list above) → deny by default.
fn github_path_allowed(method: &str, path: &str, allowed: &[String]) -> bool {
let p = path.split_once('?').map(|(p, _)| p).unwrap_or(path);
// Reject traversal anywhere in the path. GitHub normalises `..`
// upstream and would otherwise serve a different (blocked) repo.
// Reject `..` traversal anywhere. GitHub server-normalises `..`
// and would otherwise resolve to a different repo than the one
// we checked.
for seg in p.split('/') {
if seg == ".." {
return false;
}
}
// Narrow /user/* surface to what gh actually needs for auth +
// org-membership checks. Excludes /user/repos, /user/keys,
// /user/emails, /user/gpg_keys (PII / full-inventory leak).
if p == "/user" || p == "/user/orgs" || p.starts_with("/user/orgs/") {
// Reads — unrestricted.
if matches!(method, "GET" | "HEAD") {
return true;
}
// Utility endpoints — read-only, no per-repo state.
if matches!(p, "/rate_limit" | "/meta" | "/markdown") {
return true;
}
if p == "/search" || p.starts_with("/search/") {
return true;
}
if p == "/orgs" {
return false; // listing orgs isn't useful and exposes membership
}
// gh resolves `gh repo view org/x` via /orgs/<org> first; allow
// bare org-info reads (no /repos sub-path which would be a list).
if let Some(rest) = p.strip_prefix("/orgs/") {
return !rest.is_empty() && !rest.contains('/');
}
// GraphQL: gh PR/issue creation. No body-filter — see fn doc.
// GraphQL: allow (no body-level filtering in v1).
if p == "/graphql" {
return true;
}
// Utility / write-but-not-other-repo-mutating endpoints.
if matches!(p, "/markdown" | "/user/repos") {
return true;
}
// Repo-scoped writes: only against the allow-list.
if let Some(rest) = p.strip_prefix("/repos/") {
let mut it = rest.split('/');
let owner = it.next().unwrap_or("");
@ -364,10 +367,12 @@ fn github_path_allowed(path: &str, allowed: &[String]) -> bool {
return false;
}
let slug = format!("{owner}/{repo}");
return allowed
.iter()
.any(|a| a.eq_ignore_ascii_case(&slug));
return allowed.iter().any(|a| a.eq_ignore_ascii_case(&slug));
}
// Anything else (writes outside the explicit allow set above) is
// denied. Reaching here means a write to e.g. /user/keys, /orgs/X
// settings, /admin/* — none of which is a normal gh CLI flow.
false
}
@ -385,17 +390,22 @@ enum GithubSmartDecision {
}
/// Check the first line of `request` against the per-launch repo
/// allow-list. github.com smart-HTTP URLs look like:
/// allow-list, applying the same read-unrestricted / writes-scoped
/// policy as the REST API: clone and fetch (`git-upload-pack`,
/// codeload archives, raw blobs) are allowed against any repo;
/// push (`git-receive-pack`) is scoped.
///
/// GET /<owner>/<repo>.git/info/refs?service=git-upload-pack
/// POST /<owner>/<repo>.git/git-upload-pack
/// POST /<owner>/<repo>.git/git-receive-pack
/// github.com smart-HTTP URLs (in scope for filtering):
///
/// GET /<owner>/<repo>.git/info/refs?service=git-upload-pack ← read
/// POST /<owner>/<repo>.git/git-upload-pack ← read
/// GET /<owner>/<repo>.git/info/refs?service=git-receive-pack ← write (push handshake)
/// POST /<owner>/<repo>.git/git-receive-pack ← write (push data)
///
/// codeload, raw, objects host paths look like
/// `/<owner>/<repo>/...`. The first two path segments are always the
/// owner+repo (with optional `.git` suffix on github.com smart paths).
/// We strip the `.git` suffix from the repo name and check the
/// `<owner>/<repo>` slug case-insensitively against `allowed_repos`.
/// `/<owner>/<repo>/...` and are always read-only — `git clone` /
/// `git fetch` and tarball downloads. Allowed unconditionally; no
/// owner/repo check.
fn github_smart_decision(request: &[u8], allowed_repos: &[String]) -> GithubSmartDecision {
let line_end = match request.windows(2).position(|w| w == b"\r\n") {
Some(p) => p,
@ -405,9 +415,9 @@ fn github_smart_decision(request: &[u8], allowed_repos: &[String]) -> GithubSmar
Ok(s) => s,
Err(_) => return GithubSmartDecision::Malformed,
};
// GET /<owner>/<repo>... HTTP/1.1
// METHOD path HTTP/1.1
let mut parts = line.split_ascii_whitespace();
let _method = match parts.next() {
let method = match parts.next() {
Some(m) => m,
None => return GithubSmartDecision::Malformed,
};
@ -415,11 +425,14 @@ fn github_smart_decision(request: &[u8], allowed_repos: &[String]) -> GithubSmar
Some(p) => p,
None => return GithubSmartDecision::Malformed,
};
// Strip query string + leading slash for splitting.
let path_no_query = path.split_once('?').map(|(p, _)| p).unwrap_or(path);
let (path_no_query, query) = match path.split_once('?') {
Some((p, q)) => (p, q),
None => (path, ""),
};
let trimmed = path_no_query.trim_start_matches('/');
// Reject `..` traversal up front (mirrors github_path_allowed).
// Reject `..` traversal up front. Server normalisation would
// otherwise pick a different repo than the one we checked.
for seg in trimmed.split('/') {
if seg == ".." {
return GithubSmartDecision::Deny(format!(
@ -428,25 +441,38 @@ fn github_smart_decision(request: &[u8], allowed_repos: &[String]) -> GithubSmar
}
}
// Identify the *operation*. Only the write paths need an allow-
// list check; everything else (reads, browsing, archive downloads)
// passes through.
let is_push_data = method == "POST" && path_no_query.ends_with("/git-receive-pack");
let is_push_handshake = method == "GET"
&& path_no_query.ends_with("/info/refs")
&& query.split('&').any(|kv| kv == "service=git-receive-pack");
if !(is_push_data || is_push_handshake) {
// Read / clone / fetch / browse / archive — allowed against
// any repo.
return GithubSmartDecision::Passthrough;
}
// It's a push. Extract owner/repo from the first two path
// segments and check the allow-list.
let mut it = trimmed.split('/');
let owner = it.next().unwrap_or("");
let repo_raw = it.next().unwrap_or("");
if owner.is_empty() || repo_raw.is_empty() {
return GithubSmartDecision::Deny(format!(
"agent-vm: path {path:?} doesn't name an owner/repo"
"agent-vm: push path {path:?} doesn't name an owner/repo"
));
}
// git smart-HTTP paths put the repo as `<repo>.git`. trim_end_matches
// strips repeated suffixes — fine because GitHub doesn't accept
// `.git.git` as a real repo, but as a safety we only strip ONE
// suffix.
// Strip a single trailing `.git`; git smart paths are
// `<repo>.git/...`. `strip_suffix` removes exactly one.
let repo = repo_raw.strip_suffix(".git").unwrap_or(repo_raw);
let slug = format!("{owner}/{repo}");
if allowed_repos.iter().any(|a| a.eq_ignore_ascii_case(&slug)) {
GithubSmartDecision::Passthrough
} else {
GithubSmartDecision::Deny(format!(
"agent-vm: {slug:?} not in per-launch repo allow-list. Allowed: {}",
"agent-vm: push to {slug:?} blocked by per-launch repo allow-list (reads are unrestricted; pushes are scoped). Allowed: {}",
if allowed_repos.is_empty() {
"(none)".into()
} else {