Skip to content

Commit 561c026

Browse files
feat(hosted): add file-only repair mode (#163)
* feat(hosted): add file-only repair mode * fix(hosted): allow repair file edits * harden(hosted-repair): block bypass combo + document containment boundary - --hosted-repair now conflicts_with --dangerously-skip-permissions so the starved tool surface can't be widened into full bypass (shell/network) - add load-bearing SECURITY comment on the file-tool allowlist: since github_context forces BypassPermissions, this allowlist is the sole containment boundary and is guarded by the exhaustive filter test - extend the CLI conflict test to assert the new rejection --------- Co-authored-by: Val Alexander <bunsthedev@gmail.com>
1 parent af61be3 commit 561c026

6 files changed

Lines changed: 350 additions & 9 deletions

File tree

docs/advanced.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,9 @@ policy settings such as `hostedReview.allowManagedRules`,
571571
`hostedReview.allowWriteTools`, `hostedReview.allowMcpServers`, and
572572
`hostedReview.allowPlugins` can opt specific surfaces back in for controlled
573573
deployments. Prefer tenant-approved managed rules over `allowUserMemory`.
574+
Automated GitHub repair should use the dedicated `--hosted-repair` headless
575+
flag, which exposes only repository file tools and never shell, web, plugin,
576+
MCP, task, or sub-agent tools.
574577

575578
Session memory extraction is also approval-gated in hosted review mode.
576579
Untrusted fork or contributor sessions cannot automatically append learned

docs/configuration.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,14 @@ shared surfaces back in:
153153
}
154154
```
155155

156+
The GitHub worker's `--hosted-repair` mode is narrower than
157+
`allowWriteTools`: it enables only `Read`, `Grep`, `Glob`, `Edit`, `Write`,
158+
`ApplyPatch`, `BatchEdit`, and `NotebookEdit`. It requires the complete
159+
`--headless --context ... --output ...` invocation and still disables command,
160+
network-tool, sub-agent, plugin, MCP, user-memory, and automatic-memory access.
161+
Use this worker-controlled mode for automated branch repair; do not enable the
162+
broader `allowWriteTools` setting for contributor-controlled reviews.
163+
156164
`allowUserMemory` also exists for explicitly trusted deployments, but hosted
157165
review jobs should prefer tenant-approved managed rules over operator-global
158166
user memory.

docs/headless-contract.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
**Contract version: `3`** - Status: **Locked** (V3 / memory provenance in review artifacts)
44

5+
The runtime also accepts version `2` briefs during the coven-github migration
6+
and echoes `"2"` in their result envelopes. Version `3` remains the normative
7+
schema; all other major versions are rejected.
8+
59
This document is the single source of truth for the interface between
610
`coven-github` (the GitHub ingress adapter) and `coven-code` (the execution
711
runtime) when the runtime is invoked in headless mode.
@@ -31,6 +35,10 @@ The adapter spawns the runtime as a child process:
3135
coven-code --headless --context <session-brief.json> --output <result.json>
3236
```
3337

38+
Trusted branch-repair workers add `--hosted-repair`. That flag requires all
39+
three structured headless arguments and exposes only repository file tools;
40+
the control plane, not the runtime model, performs validation, commit, and push.
41+
3442
| Flag | Meaning |
3543
|---|---|
3644
| `--headless` | Disables the ratatui TUI entirely. All human-facing output is suppressed; the process is non-interactive and reads no stdin. |

src-rust/crates/cli/src/headless.rs

Lines changed: 155 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use std::path::{Path, PathBuf};
3434

3535
/// Major contract version this build implements (contract §6).
3636
pub const CONTRACT_VERSION: &str = "3";
37+
pub const COMPATIBLE_CONTRACT_VERSIONS: &[&str] = &["2", "3"];
3738

3839
/// Environment variable carrying the GitHub App **installation access token**
3940
/// used to authenticate `git push`. This is the ONLY git credential channel; it
@@ -150,11 +151,11 @@ impl SessionBrief {
150151
/// "Consumers MUST reject a payload whose major version they do not
151152
/// implement, rather than silently mis-parsing it."
152153
pub fn ensure_supported_version(&self) -> anyhow::Result<()> {
153-
if self.contract_version != CONTRACT_VERSION {
154+
if !COMPATIBLE_CONTRACT_VERSIONS.contains(&self.contract_version.as_str()) {
154155
bail!(
155156
"unsupported headless contract version {:?}; this build implements {:?}",
156157
self.contract_version,
157-
CONTRACT_VERSION
158+
COMPATIBLE_CONTRACT_VERSIONS
158159
);
159160
}
160161
Ok(())
@@ -195,6 +196,17 @@ impl SessionBrief {
195196

196197
/// Build the first-turn user prompt injected into the headless session.
197198
pub fn to_prompt(&self) -> String {
199+
self.to_prompt_for_mode(false)
200+
}
201+
202+
/// Build the first-turn prompt for a trusted hosted repair. The runtime may
203+
/// edit repository files, while the worker remains responsible for trusted
204+
/// validation, committing, and pushing.
205+
pub fn to_hosted_repair_prompt(&self) -> String {
206+
self.to_prompt_for_mode(true)
207+
}
208+
209+
fn to_prompt_for_mode(&self, hosted_repair: bool) -> String {
198210
let mut lines = vec![
199211
format!(
200212
"You are {}, the Coven coding familiar assigned to {}/{} through the coven-github App.",
@@ -268,6 +280,23 @@ impl SessionBrief {
268280
lines.push(instruction.trim().to_string());
269281
}
270282

283+
if hosted_repair {
284+
lines.push(String::new());
285+
lines.push(
286+
"Hosted repair mode: inspect the cited findings and make only the repository file edits needed to fix them. Use the available file read and edit tools directly in the current workspace. Do not merely describe a patch."
287+
.to_string(),
288+
);
289+
lines.push(
290+
"Command execution, network access, git operations, and test execution are intentionally unavailable. Do not create a branch, commit, or push. The trusted worker will validate, commit, and push the edited workspace after this runtime exits."
291+
.to_string(),
292+
);
293+
lines.push(
294+
"If no safe source-backed edit can be made, explain the blocker. Otherwise, finish with a concise summary of the files you actually changed."
295+
.to_string(),
296+
);
297+
return lines.join("\n");
298+
}
299+
271300
if self.review_mode() != ReviewMode::None {
272301
lines.push(String::new());
273302
lines.push(
@@ -331,6 +360,7 @@ pub fn apply_to_config(config: &mut claurst_core::config::Config, brief: &Sessio
331360
config.hosted_review.enabled = true;
332361
config.hosted_review.allow_user_memory = false;
333362
config.hosted_review.allow_write_tools = false;
363+
config.hosted_review.allow_file_write_tools = false;
334364
config.hosted_review.allow_mcp_servers = false;
335365
config.hosted_review.allow_plugins = false;
336366
config.hosted_review.allow_auto_memory_persistence = false;
@@ -418,6 +448,7 @@ pub struct GitSummary {
418448
pub branch: Option<String>,
419449
pub commits: Vec<CommitSummary>,
420450
pub files_changed: Vec<String>,
451+
pub workspace_dirty: bool,
421452
}
422453

423454
/// Inspect the workspace and summarize the branch, the commits ahead of the base
@@ -448,6 +479,8 @@ pub fn collect_git_summary(workspace: &Path, default_branch: &str) -> GitSummary
448479
})
449480
.unwrap_or_default();
450481

482+
let workspace_dirty = git_stdout(workspace, &["diff", "--name-only", "HEAD"]).is_some()
483+
|| git_stdout(workspace, &["diff", "--name-only", "--cached"]).is_some();
451484
let files_changed = git_stdout(workspace, &["diff", "--name-only", "HEAD"])
452485
.into_iter()
453486
.chain(git_stdout(workspace, &["diff", "--name-only", "--cached"]))
@@ -470,6 +503,7 @@ pub fn collect_git_summary(workspace: &Path, default_branch: &str) -> GitSummary
470503
branch,
471504
commits,
472505
files_changed,
506+
workspace_dirty,
473507
}
474508
}
475509

@@ -1467,6 +1501,7 @@ fn build_result(
14671501
final_text,
14681502
review_trace,
14691503
ReviewMemoryUse::default(),
1504+
false,
14701505
)
14711506
}
14721507

@@ -1479,10 +1514,11 @@ pub fn build_result_with_memory(
14791514
final_text: &str,
14801515
review_trace: Option<&ReviewTrace>,
14811516
memory: ReviewMemoryUse,
1517+
hosted_repair: bool,
14821518
) -> (ResultEnvelope, i32) {
1483-
let comment_only = brief.map(SessionBrief::is_comment_only).unwrap_or(false);
1484-
let (mut status, mut exit_reason, code) =
1485-
classify(outcome, !git.commits.is_empty(), comment_only);
1519+
let comment_only = !hosted_repair && brief.map(SessionBrief::is_comment_only).unwrap_or(false);
1520+
let has_progress = !git.commits.is_empty() || (hosted_repair && git.workspace_dirty);
1521+
let (mut status, mut exit_reason, code) = classify(outcome, has_progress, comment_only);
14861522

14871523
let review = ReviewResult::from_brief_with_memory(brief, review_trace, final_text, memory);
14881524
if review.mode != ReviewMode::None
@@ -1496,7 +1532,9 @@ pub fn build_result_with_memory(
14961532
let pr_body = compose_pr_body(brief, final_text, git, status);
14971533

14981534
let envelope = ResultEnvelope {
1499-
contract_version: CONTRACT_VERSION.to_string(),
1535+
contract_version: brief
1536+
.map(|value| value.contract_version.clone())
1537+
.unwrap_or_else(|| CONTRACT_VERSION.to_string()),
15001538
status,
15011539
branch: git.branch.clone(),
15021540
commits: git.commits.clone(),
@@ -1518,7 +1556,9 @@ pub fn infra_error_result(
15181556
) -> (ResultEnvelope, i32) {
15191557
let name = familiar_name(brief);
15201558
let envelope = ResultEnvelope {
1521-
contract_version: CONTRACT_VERSION.to_string(),
1559+
contract_version: brief
1560+
.map(|value| value.contract_version.clone())
1561+
.unwrap_or_else(|| CONTRACT_VERSION.to_string()),
15221562
status: Status::Failure,
15231563
branch: git.branch.clone(),
15241564
commits: git.commits.clone(),
@@ -1678,6 +1718,19 @@ mod tests {
16781718
serde_json::from_str(raw).expect("review brief parses")
16791719
}
16801720

1721+
fn sample_repair_brief() -> SessionBrief {
1722+
let raw = r#"{
1723+
"contract_version": "2",
1724+
"trigger": "pull_request_autoreview",
1725+
"repo": { "owner": "o", "name": "r", "clone_url": "https://github.com/o/r.git", "default_branch": "main" },
1726+
"task": { "kind": "respond_to_mention", "issue_number": 7, "comment_body": "Fix the source-backed finding." },
1727+
"familiar": { "id": "cody", "display_name": "Cody", "skills": [] },
1728+
"workspace": { "root": "/tmp/ws" },
1729+
"audit_instruction": "Edit src/lib.rs to fix the finding."
1730+
}"#;
1731+
serde_json::from_str(raw).expect("repair brief parses")
1732+
}
1733+
16811734
fn review_workspace() -> (tempfile::TempDir, ReviewTrace) {
16821735
let dir = tempfile::tempdir().unwrap();
16831736
let ws = dir.path().to_path_buf();
@@ -1793,6 +1846,7 @@ mod tests {
17931846
"## Findings\n- [low] src/lib.rs:1 — fine\n\n## Supporting Context Used\n- src/support.rs: checked",
17941847
Some(&trace),
17951848
memory,
1849+
false,
17961850
);
17971851

17981852
let value = serde_json::to_value(&envelope).unwrap();
@@ -1916,6 +1970,33 @@ mod tests {
19161970
);
19171971
}
19181972

1973+
#[test]
1974+
fn contract_v2_pull_request_review_remains_supported_end_to_end() {
1975+
let raw = r#"{
1976+
"contract_version": "2",
1977+
"trigger": "pull_request_autoreview",
1978+
"repo": { "owner": "OpenCoven", "name": "example", "clone_url": "https://github.com/OpenCoven/example.git", "default_branch": "main" },
1979+
"task": { "kind": "respond_to_mention", "issue_number": 73, "comment_body": "Review pull request #73 at the captured head." },
1980+
"familiar": { "id": "covencat", "display_name": "Covencat", "skills": [] },
1981+
"workspace": { "root": "/workspace" },
1982+
"review_context": { "kind": "pull_request", "files": [{ "filename": "src/app.ts" }] }
1983+
}"#;
1984+
let brief: SessionBrief = serde_json::from_str(raw).expect("v2 review brief parses");
1985+
brief
1986+
.ensure_supported_version()
1987+
.expect("v2 remains supported");
1988+
assert_eq!(brief.review_mode(), ReviewMode::PullRequest);
1989+
assert!(brief.to_prompt().contains("Review pull request #73"));
1990+
let (envelope, _) = build_result(
1991+
Some(&brief),
1992+
&GitSummary::default(),
1993+
RunOutcome::Completed,
1994+
"### Files inspected\n- `src/app.ts`\n### Supporting context used\n- `src/app.ts` - reviewed source\n### Findings\nNone\n### No-findings justification\nNo defect was found in `src/app.ts`.\n### Tests/commands considered\n- `npm test` - not run: host validates\n### Confidence/limitations\nHost validation is pending.",
1995+
None,
1996+
);
1997+
assert_eq!(envelope.contract_version, "2");
1998+
}
1999+
19192000
#[test]
19202001
fn prompt_is_derived_from_task_and_never_leaks_a_token() {
19212002
let brief = sample_brief();
@@ -1928,6 +2009,16 @@ mod tests {
19282009
assert!(!prompt.contains("x-access-token:"));
19292010
}
19302011

2012+
#[test]
2013+
fn hosted_repair_prompt_requires_real_edits_and_delegates_privileged_steps() {
2014+
let prompt = sample_brief().to_hosted_repair_prompt();
2015+
2016+
assert!(prompt.contains("Do not merely describe a patch"));
2017+
assert!(prompt.contains("current workspace"));
2018+
assert!(prompt.contains("trusted worker will validate, commit, and push"));
2019+
assert!(!prompt.contains("make the change on a new branch"));
2020+
}
2021+
19312022
#[test]
19322023
fn review_prompt_requires_structured_review_sections() {
19332024
let prompt = sample_review_brief().to_prompt();
@@ -1953,6 +2044,7 @@ mod tests {
19532044
let mut config = claurst_core::config::Config {
19542045
hosted_review: claurst_core::hosted_review::HostedReviewConfig {
19552046
allow_write_tools: true,
2047+
allow_file_write_tools: true,
19562048
allow_mcp_servers: true,
19572049
allow_plugins: true,
19582050
allow_user_memory: true,
@@ -1966,6 +2058,7 @@ mod tests {
19662058

19672059
assert!(config.hosted_review.enabled);
19682060
assert!(!config.hosted_review.allow_write_tools);
2061+
assert!(!config.hosted_review.allow_file_write_tools);
19692062
assert!(!config.hosted_review.allow_mcp_servers);
19702063
assert!(!config.hosted_review.allow_plugins);
19712064
assert!(!config.hosted_review.allow_user_memory);
@@ -2065,6 +2158,7 @@ mod tests {
20652158
message: "Add clock-skew buffer".to_string(),
20662159
}],
20672160
files_changed: vec!["src/auth/refresh.rs".to_string()],
2161+
workspace_dirty: false,
20682162
};
20692163
let (env, code) = build_result(
20702164
Some(&sample_brief()),
@@ -2417,6 +2511,52 @@ N/A
24172511
}
24182512
}
24192513

2514+
#[test]
2515+
fn hosted_repair_requires_a_workspace_edit() {
2516+
let brief = sample_repair_brief();
2517+
let git = GitSummary {
2518+
files_changed: vec!["src/lib.rs".to_string()],
2519+
workspace_dirty: false,
2520+
..Default::default()
2521+
};
2522+
let (env, code) = build_result_with_memory(
2523+
Some(&brief),
2524+
&git,
2525+
RunOutcome::Completed,
2526+
"No changes were necessary.",
2527+
None,
2528+
ReviewMemoryUse::default(),
2529+
true,
2530+
);
2531+
2532+
assert_eq!(code, 1);
2533+
assert_eq!(env.status, Status::Failure);
2534+
assert_eq!(env.exit_reason, Some(ExitReason::AmbiguousSpec));
2535+
}
2536+
2537+
#[test]
2538+
fn hosted_repair_accepts_uncommitted_workspace_edits_for_worker_validation() {
2539+
let brief = sample_repair_brief();
2540+
let git = GitSummary {
2541+
files_changed: vec!["src/lib.rs".to_string()],
2542+
workspace_dirty: true,
2543+
..Default::default()
2544+
};
2545+
let (env, code) = build_result_with_memory(
2546+
Some(&brief),
2547+
&git,
2548+
RunOutcome::Completed,
2549+
"Updated `src/lib.rs` to address the finding.",
2550+
None,
2551+
ReviewMemoryUse::default(),
2552+
true,
2553+
);
2554+
2555+
assert_eq!(code, 0);
2556+
assert_eq!(env.status, Status::Success);
2557+
assert!(env.exit_reason.is_none());
2558+
}
2559+
24202560
#[test]
24212561
fn address_review_comment_without_commits_is_successful_review_output() {
24222562
let raw = r#"{
@@ -2457,6 +2597,7 @@ N/A
24572597
message: "do the thing".to_string(),
24582598
}],
24592599
files_changed: vec!["a.rs".to_string()],
2600+
workspace_dirty: false,
24602601
};
24612602
let (env, _) = build_result(
24622603
Some(&sample_brief()),
@@ -2481,6 +2622,7 @@ N/A
24812622
message: "m".to_string(),
24822623
}],
24832624
files_changed: vec!["a.rs".to_string(), "b.rs".to_string()],
2625+
workspace_dirty: false,
24842626
};
24852627
let (env, _) = build_result(
24862628
Some(&sample_brief()),
@@ -2541,6 +2683,12 @@ N/A
25412683
assert_eq!(summary.commits.len(), 1, "one commit ahead of main");
25422684
assert_eq!(summary.commits[0].message, "add feature");
25432685
assert!(summary.files_changed.iter().any(|f| f == "feature.rs"));
2686+
assert!(!summary.workspace_dirty);
2687+
2688+
std::fs::write(ws.join("base.txt"), "edited").unwrap();
2689+
let dirty = collect_git_summary(ws, "main");
2690+
assert!(dirty.workspace_dirty);
2691+
assert!(dirty.files_changed.iter().any(|f| f == "base.txt"));
25442692
}
25452693

25462694
#[test]

0 commit comments

Comments
 (0)