Skip to content

Commit 6890234

Browse files
authored
feat(worker,github): fork-PR trust hardening for memory writes (#6) (#52)
Detect cross-repo (fork) PRs from the PR metadata (head repo id != base, or a deleted head repo) and thread head_is_fork through ResolvedTargets. The memory trust derivation now maps a fork PR to ForkPr — which grants no write scope — overriding even a maintainer trigger, since the risk is the untrusted content, not the actor. Closes the follow-up flagged in #50: auto and command reviews of fork content can no longer write durable memory even with approval. Signed-off-by: Val Alexander <bunsthedev@gmail.com>
1 parent c527114 commit 6890234

3 files changed

Lines changed: 104 additions & 9 deletions

File tree

crates/github/src/repo.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ pub struct PullRequestRefs {
2121
pub head_sha: String,
2222
pub base_ref: String,
2323
pub base_sha: String,
24+
/// True when the PR head lives in a different repository than the base — a
25+
/// cross-repo (fork) PR carrying untrusted content. Drives the memory
26+
/// trust decision (issue #6): fork content can never write durable memory.
27+
pub head_is_fork: bool,
2428
}
2529

2630
#[derive(Debug, Deserialize)]
@@ -39,11 +43,31 @@ struct PullRequestMetaResponse {
3943
base: PrRef,
4044
}
4145

46+
impl PullRequestMetaResponse {
47+
/// A PR is a fork PR when its head repository differs from its base. A
48+
/// missing head repo means the fork was deleted — treated as a fork
49+
/// (untrusted) so memory stays fail-closed.
50+
fn head_is_fork(&self) -> bool {
51+
match (self.head.repo.as_ref(), self.base.repo.as_ref()) {
52+
(Some(head), Some(base)) => head.id != base.id,
53+
_ => true,
54+
}
55+
}
56+
}
57+
4258
#[derive(Debug, Deserialize)]
4359
struct PrRef {
4460
#[serde(rename = "ref")]
4561
ref_name: String,
4662
sha: String,
63+
/// The repository the ref lives in; absent when a fork has been deleted.
64+
#[serde(default)]
65+
repo: Option<PrRepoRef>,
66+
}
67+
68+
#[derive(Debug, Deserialize)]
69+
struct PrRepoRef {
70+
id: u64,
4771
}
4872

4973
/// Fetches repository metadata (default branch, etc.).
@@ -137,11 +161,13 @@ pub async fn get_pull_request_refs_with_base_url(
137161
)
138162
.await?;
139163
let body: PullRequestMetaResponse = response.json().await?;
164+
let head_is_fork = body.head_is_fork();
140165
Ok(PullRequestRefs {
141166
head_ref: body.head.ref_name,
142167
head_sha: body.head.sha,
143168
base_ref: body.base.ref_name,
144169
base_sha: body.base.sha,
170+
head_is_fork,
145171
})
146172
}
147173

@@ -275,6 +301,38 @@ mod tests {
275301
assert_eq!(body.base.ref_name, "develop");
276302
assert_eq!(body.base.sha, "basesha");
277303
}
304+
305+
#[test]
306+
fn same_repo_pr_is_not_a_fork() {
307+
let body: PullRequestMetaResponse = serde_json::from_value(json!({
308+
"head": { "ref": "feature", "sha": "h", "repo": { "id": 1 } },
309+
"base": { "ref": "main", "sha": "b", "repo": { "id": 1 } }
310+
}))
311+
.unwrap();
312+
assert!(!body.head_is_fork());
313+
}
314+
315+
#[test]
316+
fn cross_repo_pr_is_a_fork() {
317+
let body: PullRequestMetaResponse = serde_json::from_value(json!({
318+
"head": { "ref": "feature", "sha": "h", "repo": { "id": 2 } },
319+
"base": { "ref": "main", "sha": "b", "repo": { "id": 1 } }
320+
}))
321+
.unwrap();
322+
assert!(body.head_is_fork());
323+
}
324+
325+
#[test]
326+
fn deleted_or_absent_head_repo_is_treated_as_a_fork() {
327+
// A fork whose repo was deleted (head.repo null) is untrusted — memory
328+
// must stay fail-closed rather than assume same-repo.
329+
let body: PullRequestMetaResponse = serde_json::from_value(json!({
330+
"head": { "ref": "feature", "sha": "h" },
331+
"base": { "ref": "main", "sha": "b", "repo": { "id": 1 } }
332+
}))
333+
.unwrap();
334+
assert!(body.head_is_fork());
335+
}
278336
}
279337

280338
/// Fetches the repository permission level GitHub reports for a user:

crates/worker/src/lib.rs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -630,20 +630,15 @@ async fn run_and_publish(
630630
};
631631
// Compute the memory governance policy (issue #6). Deny-by-default: unless
632632
// the installation opts memory in for this repo, this is None and no policy
633-
// is stamped, so the runtime does no memory work. Trust: a commander here
634-
// already passed the #13 write-access gate (below-write is declined
635-
// earlier), so a command carries maintainer trust; auto-triggered work gets
636-
// the safe collaborator default. Fork-PR hardening is a follow-up.
633+
// is stamped, so the runtime does no memory work. Trust is derived from the
634+
// review target and actor: a fork PR is untrusted content and can never
635+
// write durable memory, overriding even a maintainer trigger.
637636
let repo_full = format!("{}/{}", task.repo_owner, task.repo_name);
638637
let memory_policy = memory::compute_policy(memory::PolicyInputs {
639638
enabled: config.memory.enabled_for(&repo_full),
640639
installation_id: task.installation_id,
641640
repo: &repo_full,
642-
trust: if task.commander.is_some() {
643-
memory::TrustScope::Maintainer
644-
} else {
645-
memory::TrustScope::Collaborator
646-
},
641+
trust: memory::derive_trust(targets.head_is_fork, task.commander.is_some()),
647642
approval_required: config.memory.approval_required_for(&repo_full),
648643
retention_days: config.memory.retention_days,
649644
});
@@ -1279,6 +1274,9 @@ struct ResolvedTargets {
12791274
base_ref: String,
12801275
/// Immutable commit SHA the Check Run attaches to.
12811276
head_sha: String,
1277+
/// True when this task reviews a fork PR — untrusted content that can never
1278+
/// write durable memory (issue #6). Always false for non-PR tasks.
1279+
head_is_fork: bool,
12821280
}
12831281

12841282
/// Resolves the repository default branch and the immutable target refs a task
@@ -1303,6 +1301,7 @@ async fn resolve_targets(api_base_url: &str, token: &str, task: &Task) -> Result
13031301
default_branch: meta.default_branch,
13041302
base_ref: refs.base_ref,
13051303
head_sha: refs.head_sha,
1304+
head_is_fork: refs.head_is_fork,
13061305
})
13071306
}
13081307
TaskKind::FixIssue { .. }
@@ -1321,6 +1320,7 @@ async fn resolve_targets(api_base_url: &str, token: &str, task: &Task) -> Result
13211320
base_ref: meta.default_branch.clone(),
13221321
default_branch: meta.default_branch,
13231322
head_sha,
1323+
head_is_fork: false,
13241324
})
13251325
}
13261326
}
@@ -2128,6 +2128,7 @@ exit 0
21282128
default_branch: "main".to_string(),
21292129
base_ref: "main".to_string(),
21302130
head_sha: "abc123".to_string(),
2131+
head_is_fork: false,
21312132
};
21322133
let workspace = root.join("ws");
21332134

crates/worker/src/memory.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,24 @@ pub fn compute_policy(input: PolicyInputs) -> Option<MemoryPolicy> {
103103
})
104104
}
105105

106+
/// Derives the actor trust level for a memory decision (issue #6).
107+
///
108+
/// A fork PR is untrusted **content** — planted facts could be written into
109+
/// durable memory to poison later reviews — so `head_is_fork` maps to
110+
/// [`TrustScope::ForkPr`] and **overrides** the actor's own standing, even a
111+
/// maintainer who triggered the review. Otherwise a commander (who already
112+
/// passed the #13 write-access gate) is a maintainer, and auto-triggered work
113+
/// gets the safe collaborator default.
114+
pub fn derive_trust(head_is_fork: bool, has_commander: bool) -> TrustScope {
115+
if head_is_fork {
116+
TrustScope::ForkPr
117+
} else if has_commander {
118+
TrustScope::Maintainer
119+
} else {
120+
TrustScope::Collaborator
121+
}
122+
}
123+
106124
/// Default `(read, write)` namespace grants per trust level (contract's table).
107125
/// The load-bearing rule: `ForkPr` and `External` get **no** write scope, so a
108126
/// hostile fork PR can never poison durable memory.
@@ -272,6 +290,24 @@ mod tests {
272290
assert!(compute_policy(inputs(TrustScope::Maintainer, false)).is_none());
273291
}
274292

293+
#[test]
294+
fn fork_review_overrides_actor_trust() {
295+
// A fork PR is untrusted content: even a maintainer command reviewing it
296+
// is ForkPr (never writes), while a same-repo command is Maintainer.
297+
assert_eq!(derive_trust(true, true), TrustScope::ForkPr);
298+
assert_eq!(derive_trust(true, false), TrustScope::ForkPr);
299+
assert_eq!(derive_trust(false, true), TrustScope::Maintainer);
300+
assert_eq!(derive_trust(false, false), TrustScope::Collaborator);
301+
302+
// And the fork grant carries through to no write scope.
303+
let policy = compute_policy(PolicyInputs {
304+
trust: derive_trust(true, true),
305+
..inputs(TrustScope::Maintainer, true)
306+
})
307+
.unwrap();
308+
assert!(policy.write_scopes.is_empty(), "fork review must never write");
309+
}
310+
275311
#[test]
276312
fn fork_pr_gets_read_but_never_write() {
277313
let policy = compute_policy(inputs(TrustScope::ForkPr, true)).unwrap();

0 commit comments

Comments
 (0)