Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions config/example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,14 @@ trigger_labels = ["coven:fix", "coven:review"] # Labels that trigger this famil
# audit_instruction = "Focus on correctness and security."
# [review.repos."OpenCoven/coven-code"]
# pull_request = false # Per-repo override

# ── Hosted memory governance (issue #6) ─────────────────────────────────────
# Off by default. Enabling memory is a hosted decision coordinated with the
# coven-code side of the contract (docs/memory-contract.md). Fork and external
# actors can never write durable memory regardless of these settings.
# [memory]
# enabled = false
# approval_required = true # learned facts stay pending until a maintainer approves
# retention_days = 365
# [memory.repos."acme/billing"]
# enabled = true # per-repo opt-in
129 changes: 129 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,59 @@ pub struct Config {
/// Durable adapter state (issue #2). Absent section = default path.
#[serde(default)]
pub storage: StorageConfig,
/// Hosted memory governance policy (issue #6). Absent section = memory off.
#[serde(default)]
pub memory: MemoryConfig,
}

/// Hosted memory governance policy (issue #6). Off by default; opting in is a
/// hosted decision coordinated with the coven-code side of the contract (see
/// `docs/memory-contract.md`).
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct MemoryConfig {
/// Master opt-in. `false` (or section absent) → the adapter emits no memory
/// policy and the runtime does no memory work.
#[serde(default)]
pub enabled: bool,
/// Written memory stays `pending` until a maintainer approves it.
#[serde(default = "default_true")]
pub approval_required: bool,
/// Optional retention horizon for durable memory.
pub retention_days: Option<u32>,
/// Per-repo overrides keyed "owner/name".
#[serde(default)]
pub repos: std::collections::HashMap<String, RepoMemoryOverride>,
}
Comment on lines +27 to +41

/// Per-repo override of the global [`MemoryConfig`]; unset fields inherit.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct RepoMemoryOverride {
pub enabled: Option<bool>,
pub approval_required: Option<bool>,
}

impl MemoryConfig {
fn overrides(&self, repo: &str) -> Option<&RepoMemoryOverride> {
self.repos.get(repo)
}

/// Whether memory is opted in for `repo` ("owner/name").
pub fn enabled_for(&self, repo: &str) -> bool {
self.overrides(repo)
.and_then(|o| o.enabled)
.unwrap_or(self.enabled)
}

/// Whether writes for `repo` require maintainer approval.
pub fn approval_required_for(&self, repo: &str) -> bool {
self.overrides(repo)
.and_then(|o| o.approval_required)
.unwrap_or(self.approval_required)
}
}

fn default_true() -> bool {
true
}

/// Durable store location. See `docs/durable-task-store.md`.
Expand Down Expand Up @@ -340,6 +393,22 @@ impl Config {
}
}

// ── Memory governance (issue #6) ────────────────────────────────
// Memory is off by default; when an operator enables it anywhere,
// warn if writes are not approval-gated — that is the posture that
// lets untrusted content shape future reviews.
let memory_on = self.memory.enabled || self.memory.repos.values().any(|o| o.enabled == Some(true));
if memory_on {
let gated = self.memory.approval_required
&& self.memory.repos.values().all(|o| o.approval_required != Some(false));
if !gated {
Comment on lines +400 to +404
out.push(Diagnostic::warning(
"memory.approval_required",
"memory is enabled with approval_required = false — learned facts write without maintainer review.",
));
}
}

out
}
}
Expand Down Expand Up @@ -423,6 +492,9 @@ fn next_step_for(field: &str, _message: &str) -> &'static str {
"storage.path" => {
"Point storage.path at a writable SQLite file location on a persistent volume."
}
"memory.approval_required" => {
"Keep memory.approval_required = true so learned facts need maintainer review, or accept the risk deliberately."
}
_ => "Update this config field, then rerun coven-github doctor.",
}
}
Expand Down Expand Up @@ -522,6 +594,7 @@ mod tests {
familiars,
review: ReviewConfig::default(),
storage: StorageConfig::default(),
memory: MemoryConfig::default(),
}
}

Expand Down Expand Up @@ -588,6 +661,62 @@ mod tests {
);
}

#[test]
fn memory_policy_defaults_off_and_resolves_repo_overrides() {
let mut memory = MemoryConfig::default();
assert!(!memory.enabled_for("acme/any"), "memory is off by default");

memory.enabled = true;
assert!(memory.enabled_for("acme/any"));
// approval_required serde-defaults to true, but Default::default() is
// false; set it explicitly to model the deserialized default.
memory.approval_required = true;
assert!(memory.approval_required_for("acme/any"));

memory.repos.insert(
"acme/quiet".to_string(),
RepoMemoryOverride {
enabled: Some(false),
approval_required: None,
},
);
assert!(!memory.enabled_for("acme/quiet"), "override disables the repo");
assert!(memory.enabled_for("acme/loud"));
}

#[test]
fn doctor_warns_when_memory_enabled_without_approval() {
let dir = tmpdir();
let pem = write_pem(&dir);
let bin = write_bin(&dir);
let mut cfg = config_with(
GitHubAppConfig {
app_id: 123,
private_key_path: pem,
webhook_secret: "a-long-random-webhook-secret".into(),
api_base_url: None,
},
WorkerConfig {
concurrency: 4,
coven_code_bin: bin,
workspace_root: dir.clone(),
timeout_secs: 600,
max_retries: 2,
},
vec![good_familiar()],
);
cfg.memory.enabled = true;
cfg.memory.approval_required = false;

let warned = cfg
.check()
.iter()
.any(|d| d.field == "memory.approval_required");
assert!(warned, "diags: {:?}", cfg.check());
// It is a warning, not an error — the operator may accept the risk.
assert!(errors(&cfg.check()).is_empty());
}

#[test]
fn review_policy_defaults_to_disabled() {
let policy = ReviewConfig::default();
Expand Down
93 changes: 93 additions & 0 deletions crates/github/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,58 @@ pub struct SessionResult {
pub pr_body: String,
pub review: ReviewResult,
pub exit_reason: Option<ExitReason>,
/// Memory activity the runtime reports for hosted governance (issue #6).
/// Absent/`None` when the run did no memory work. The adapter re-validates
/// every proposed write against the invocation's memory policy before
/// anything is persisted — see `docs/memory-contract.md`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_used: Option<MemoryUsed>,
}

/// Runtime-reported memory activity for one session (issue #6).
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MemoryUsed {
pub enabled: bool,
/// Entries loaded and used — the basis for citing what shaped a review.
#[serde(default)]
pub read: Vec<MemoryEntryRef>,
/// Candidate writes the runtime proposes (subject to adapter validation
/// and, when the policy requires it, maintainer approval).
#[serde(default)]
pub proposed: Vec<ProposedMemory>,
/// Candidates the runtime itself declined, with a reason.
#[serde(default)]
pub rejected: Vec<RejectedMemory>,
}

/// A memory entry the runtime read, by fully-qualified id and namespace scope.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MemoryEntryRef {
pub id: String,
pub scope: String,
}

/// A memory write the runtime proposes.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ProposedMemory {
pub key: String,
pub summary: String,
pub scope: String,
/// `pending` | `applied` | `auto`.
pub approval: String,
}

/// A memory write the runtime declined on its own.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RejectedMemory {
pub summary: String,
pub scope: String,
/// `pii` | `secret` | `out_of_scope` | `low_confidence`.
pub reason: String,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
Expand Down Expand Up @@ -369,3 +421,44 @@ pub enum ExitReason {
GitConflict,
InfraError,
}

#[cfg(test)]
mod memory_result_tests {
use super::*;

const BASE: &str = r#"{"contract_version":"2","status":"success","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{"mode":"none","evidence_status":"not_applicable","reviewed_files":[],"supporting_files":[],"findings":[],"tests_run":[],"no_findings_reason":null,"limitations":[]},"exit_reason":null"#;

#[test]
fn result_without_memory_used_parses_as_none_and_omits_on_reserialize() {
let json = format!("{BASE}}}");
let result: SessionResult = serde_json::from_str(&json).expect("parses");
assert!(result.memory_used.is_none());
// Backward compatible: a memory-free result never grows the field.
let out = serde_json::to_string(&result).expect("serializes");
assert!(!out.contains("memory_used"), "unexpected field: {out}");
}

#[test]
fn result_with_memory_used_round_trips() {
let json = format!(
r#"{BASE},"memory_used":{{"enabled":true,"read":[{{"id":"repo/o/r/conventions/x","scope":"repo"}}],"proposed":[{{"key":"repo/o/r/conventions/y","summary":"y","scope":"repo","approval":"pending"}}],"rejected":[]}}}}"#
);
let result: SessionResult = serde_json::from_str(&json).expect("parses");
let mem = result.memory_used.as_ref().expect("memory_used present");
assert!(mem.enabled);
assert_eq!(mem.read[0].id, "repo/o/r/conventions/x");
assert_eq!(mem.proposed[0].approval, "pending");

let value = serde_json::to_value(&result).expect("serializes");
assert_eq!(value["memory_used"]["proposed"][0]["scope"], "repo");
}

#[test]
fn unknown_memory_field_is_rejected() {
let json = format!(
r#"{BASE},"memory_used":{{"enabled":true,"bogus":1}}}}"#
);
let err = serde_json::from_str::<SessionResult>(&json).expect_err("deny_unknown_fields");
assert!(err.to_string().contains("bogus"), "unexpected error: {err}");
}
}
1 change: 1 addition & 0 deletions crates/webhook/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,7 @@ mod tests {
}],
review,
storage: coven_github_config::StorageConfig::default(),
memory: coven_github_config::MemoryConfig::default(),
}),
store: Store::open_in_memory().expect("in-memory store"),
notify: std::sync::Arc::new(tokio::sync::Notify::new()),
Expand Down
34 changes: 31 additions & 3 deletions crates/worker/src/brief.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ pub struct SessionBrief {
pub review_context: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audit_instruction: Option<String>,
/// Hosted memory governance policy (issue #6). Present only when the
/// installation has opted memory in for this repo; absent → memory off.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_policy: Option<serde_json::Value>,
}

#[derive(Debug, Serialize, Deserialize)]
Expand Down Expand Up @@ -105,6 +109,7 @@ pub fn build(
workspace: &Path,
default_branch: &str,
review: Option<&ReviewContext>,
memory_policy: Option<serde_json::Value>,
) -> SessionBrief {
let trigger = match &task.kind {
TaskKind::FixIssue { .. } => "issue_assigned",
Expand Down Expand Up @@ -205,6 +210,7 @@ pub fn build(
})
}),
audit_instruction: review.and_then(|r| r.audit_instruction.clone()),
memory_policy,
}
}

Expand Down Expand Up @@ -258,10 +264,31 @@ mod tests {

#[test]
fn brief_uses_resolved_default_branch_not_hardcoded_main() {
let brief = build(&task(), &familiar(), Path::new("/tmp/ws"), "develop", None);
let brief = build(&task(), &familiar(), Path::new("/tmp/ws"), "develop", None, None);
assert_eq!(brief.repo.default_branch, "develop");
}

#[test]
fn brief_omits_memory_policy_by_default_and_stamps_it_when_present() {
// No policy → the field is absent (memory off, deny-by-default).
let plain = build(&task(), &familiar(), Path::new("/tmp/ws"), "main", None, None);
assert!(plain.memory_policy.is_none());
let json = serde_json::to_string(&plain).unwrap();
assert!(!json.contains("memory_policy"), "unexpected field: {json}");

// A policy → stamped verbatim into the brief.
let policy = serde_json::json!({ "enabled": true, "repo": "acme/billing" });
let stamped = build(
&task(),
&familiar(),
Path::new("/tmp/ws"),
"main",
None,
Some(policy.clone()),
);
assert_eq!(stamped.memory_policy.as_ref(), Some(&policy));
}

#[test]
fn review_task_briefs_as_contract_v2_review_comment_with_context() {
let review = ReviewContext {
Expand All @@ -274,6 +301,7 @@ mod tests {
Path::new("/tmp/ws"),
"main",
Some(&review),
None,
);

// Contract v2 locks trigger/task enums — the review lane must ride on
Expand Down Expand Up @@ -311,14 +339,14 @@ mod tests {

#[test]
fn non_review_tasks_carry_no_review_context() {
let brief = build(&task(), &familiar(), Path::new("/tmp/ws"), "main", None);
let brief = build(&task(), &familiar(), Path::new("/tmp/ws"), "main", None, None);
assert!(brief.review_context.is_none());
assert!(brief.audit_instruction.is_none());
}

#[test]
fn brief_serialization_never_contains_token_or_auth_fields() {
let brief = build(&task(), &familiar(), Path::new("/tmp/ws"), "main", None);
let brief = build(&task(), &familiar(), Path::new("/tmp/ws"), "main", None, None);
let value = serde_json::to_value(&brief).expect("brief should serialize");
let json = serde_json::to_string(&brief).expect("brief should serialize");

Expand Down
Loading
Loading