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
24 changes: 19 additions & 5 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,11 +518,25 @@ Files can `@include` other files using a frontmatter include directive. Included

The `InstructionsLoaded` hook event fires for every file that is loaded, with the `load_reason` field indicating why (e.g. `session_start`, `nested_traversal`, `path_glob_match`, `include`, `compact`).

The `/memory` command opens the memory management UI for viewing, editing, and organising instruction files.

---

## Security and permissions
The `/memory` command opens the memory management UI for viewing, editing, and organising instruction files.

### Hosted review memory isolation

Local-personal mode is the default. It loads managed, user, project, and local
instruction scopes so a developer's personal `~/.coven-code/AGENTS.md` can
shape their own sessions.

Hosted review mode is enabled with `--hosted-review`,
`COVEN_CODE_HOSTED_REVIEW=1`, or `config.hostedReview.enabled`. In this mode,
Coven Code does not load user-scope memory by default. The prompt records that
hosted review mode is active and lists the AGENTS.md scopes that were loaded.
Durable hosted memory and transcript namespaces are separated under a hosted
review path and require tenant scope plus a canonical repository identity before
they can be resolved for persistence.

---

## Security and permissions

### Permission modes

Expand Down
30 changes: 30 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,36 @@ See [Permission Modes](#permission-modes) for a full description of each value.
| `custom_system_prompt` | string \| null | null | Replace the default Coven Code system prompt entirely with this text. |
| `append_system_prompt` | string \| null | null | Append this text to the end of the assembled system prompt (after AGENTS.md content). |

### Hosted review mode

Hosted review mode is for service-hosted code review runs where the session must
not inherit the operator's personal global memory.

Enable it with any of:

```bash
coven-code --hosted-review
COVEN_CODE_HOSTED_REVIEW=1 coven-code
```

Or in `settings.json`:

```json
{
"config": {
"hostedReview": {
"enabled": true
}
}
}
```

When hosted review mode is active, Coven Code skips user-scope memory
(`~/.coven-code/AGENTS.md` and `~/.coven-code/CLAUDE.md`) by default, marks
new session artifacts as hosted review artifacts, and requires a tenant plus
canonical repository identity before resolving hosted durable memory paths.
Local-personal mode remains the default and continues to load user memory.
Comment on lines +129 to +133

### Tool access

| Key | Type | Default | Description |
Expand Down
23 changes: 20 additions & 3 deletions src-rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,10 @@ struct Cli {
#[arg(long = "bare", action = ArgAction::SetTrue)]
bare: bool,

/// Run with hosted review memory isolation
#[arg(long = "hosted-review", env = "COVEN_CODE_HOSTED_REVIEW", action = ArgAction::SetTrue)]
hosted_review: bool,
Comment on lines +250 to +252

/// Billing workload tag
#[arg(long = "workload", value_name = "TAG")]
workload: Option<String>,
Expand Down Expand Up @@ -807,6 +811,9 @@ async fn main() -> anyhow::Result<()> {
config.verbose = cli.verbose;
config.output_format = cli.output_format.into();
config.disable_claude_mds = cli.no_claude_md;
if cli.hosted_review {
config.hosted_review.enabled = true;
}
if let Some(sp) = cli.system_prompt.clone() {
config.custom_system_prompt = Some(sp);
}
Expand Down Expand Up @@ -852,16 +859,23 @@ async fn main() -> anyhow::Result<()> {

// --dump-system-prompt fast path
if cli.dump_system_prompt {
let ctx = ContextBuilder::new(cwd.clone()).disable_claude_mds(config.disable_claude_mds);
let ctx = ContextBuilder::new(cwd.clone())
.disable_claude_mds(config.disable_claude_mds)
.memory_load_options(claurst_core::claudemd::MemoryLoadOptions::from_mode(
config.runtime_mode(),
));
let sys = ctx.build_system_context().await;
let user = ctx.build_user_context().await;
println!("{}\n\n{}", sys, user);
return Ok(());
}

// Build context
let ctx_builder =
ContextBuilder::new(cwd.clone()).disable_claude_mds(config.disable_claude_mds);
let ctx_builder = ContextBuilder::new(cwd.clone())
.disable_claude_mds(config.disable_claude_mds)
.memory_load_options(claurst_core::claudemd::MemoryLoadOptions::from_mode(
config.runtime_mode(),
));
let system_ctx = ctx_builder.build_system_context().await;
let user_ctx = ctx_builder.build_user_context().await;

Expand Down Expand Up @@ -2163,6 +2177,9 @@ async fn run_interactive(
session.working_dir = Some(tool_ctx.working_dir.display().to_string());
session
};
if config.hosted_review_enabled() {
session.hosted_review = true;
}
let initial_messages = session.messages.clone();
let mut base_query_config = query_config;
let mut live_config = config.clone();
Expand Down
2 changes: 2 additions & 0 deletions src-rust/crates/commands/src/stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1247,6 +1247,7 @@ mod tests {
git_branch: None,
agent_role: None,
managed_session_id: None,
hosted_review: false,
extra: Default::default(),
})
}
Expand All @@ -1265,6 +1266,7 @@ mod tests {
git_branch: None,
agent_role: None,
managed_session_id: None,
hosted_review: false,
extra: Default::default(),
})
}
Expand Down
5 changes: 4 additions & 1 deletion src-rust/crates/core/src/auth_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,10 @@ mod tests {
},
);

assert_eq!(store.api_key_for("anthropic").as_deref(), Some("sk-ant-key"));
assert_eq!(
store.api_key_for("anthropic").as_deref(),
Some("sk-ant-key")
);
}

/// Atomic save: a concurrent racer that creates the file mid-flight
Expand Down
139 changes: 122 additions & 17 deletions src-rust/crates/core/src/claudemd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::SystemTime;

use crate::hosted_review::RuntimeMode;

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -48,6 +50,39 @@ pub struct MemoryFileInfo {
pub mtime: Option<SystemTime>,
}

/// Controls which memory scopes are loaded for the current runtime mode.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryLoadOptions {
pub mode: RuntimeMode,
pub allow_user_memory: bool,
pub allow_managed_rules: bool,
}

impl MemoryLoadOptions {
pub fn local() -> Self {
Self {
mode: RuntimeMode::Local,
allow_user_memory: true,
allow_managed_rules: true,
}
}

pub fn hosted_review() -> Self {
Self {
mode: RuntimeMode::HostedReview,
allow_user_memory: false,
allow_managed_rules: false,
}
}

pub fn from_mode(mode: RuntimeMode) -> Self {
match mode {
RuntimeMode::Local => Self::local(),
RuntimeMode::HostedReview => Self::hosted_review(),
}
}
}

// ---------------------------------------------------------------------------
// Cache
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -238,33 +273,45 @@ fn load_scope_files(dir: &Path, scope: MemoryScope, files: &mut Vec<MemoryFileIn
///
/// Returned list is ordered: Managed (highest) → User → Project → Local.
pub fn load_all_memory_files(project_root: &Path) -> Vec<MemoryFileInfo> {
load_all_memory_files_with_options(project_root, &MemoryLoadOptions::local())
}

/// Load all memory files for the given project root using explicit scope gates.
pub fn load_all_memory_files_with_options(
project_root: &Path,
options: &MemoryLoadOptions,
) -> Vec<MemoryFileInfo> {
let mut files = Vec::new();

// 1. Managed: ~/.coven-code/rules/*.md
if let Some(home) = dirs::home_dir() {
let rules_dir = home.join(".coven-code/rules");
if let Ok(entries) = std::fs::read_dir(&rules_dir) {
let mut paths: Vec<PathBuf> = entries
.flatten()
.filter_map(|e| {
let p = e.path();
if p.extension().is_some_and(|x| x == "md") {
Some(p)
} else {
None
if options.allow_managed_rules {
let rules_dir = home.join(".coven-code/rules");
if let Ok(entries) = std::fs::read_dir(&rules_dir) {
let mut paths: Vec<PathBuf> = entries
.flatten()
.filter_map(|e| {
let p = e.path();
if p.extension().is_some_and(|x| x == "md") {
Some(p)
} else {
None
}
})
.collect();
paths.sort();
for p in paths {
if let Some(f) = load_memory_file(&p, MemoryScope::Managed) {
files.push(f);
}
})
.collect();
paths.sort();
for p in paths {
if let Some(f) = load_memory_file(&p, MemoryScope::Managed) {
files.push(f);
}
}
}

// 2. User: ~/.coven-code/AGENTS.md then ~/.coven-code/CLAUDE.md
load_scope_files(&home.join(".coven-code"), MemoryScope::User, &mut files);
if options.allow_user_memory {
load_scope_files(&home.join(".coven-code"), MemoryScope::User, &mut files);
}
}

// 3. Project: {project_root}/AGENTS.md then {project_root}/CLAUDE.md
Expand Down Expand Up @@ -352,6 +399,64 @@ mod tests {
assert!(project[0].path.ends_with("CLAUDE.md"));
}

#[test]
fn hosted_review_excludes_user_memory_by_default() {
let project = tempfile::tempdir().unwrap();
std::fs::write(project.path().join("AGENTS.md"), "project memory").unwrap();

let home = tempfile::tempdir().unwrap();
let coven_code = home.path().join(".coven-code");
std::fs::create_dir_all(&coven_code).unwrap();
std::fs::write(coven_code.join("AGENTS.md"), "user memory").unwrap();

let _lock = crate::coven_shared::COVEN_HOME_ENV_LOCK
.lock()
.unwrap_or_else(|err| err.into_inner());
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", home.path());

let files =
load_all_memory_files_with_options(project.path(), &MemoryLoadOptions::hosted_review());

match original_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}

assert!(files.iter().all(|file| file.scope != MemoryScope::User));
assert!(files.iter().any(|file| {
file.scope == MemoryScope::Project && file.content.contains("project memory")
}));
}

#[test]
fn local_memory_load_still_includes_user_memory() {
let project = tempfile::tempdir().unwrap();
std::fs::write(project.path().join("AGENTS.md"), "project memory").unwrap();

let home = tempfile::tempdir().unwrap();
let coven_code = home.path().join(".coven-code");
std::fs::create_dir_all(&coven_code).unwrap();
std::fs::write(coven_code.join("AGENTS.md"), "user memory").unwrap();

let _lock = crate::coven_shared::COVEN_HOME_ENV_LOCK
.lock()
.unwrap_or_else(|err| err.into_inner());
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", home.path());

let files = load_all_memory_files_with_options(project.path(), &MemoryLoadOptions::local());

match original_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}

assert!(files
.iter()
.any(|file| file.scope == MemoryScope::User && file.content.contains("user memory")));
}

#[test]
fn expand_includes_circular() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
64 changes: 64 additions & 0 deletions src-rust/crates/core/src/hosted_review.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
use serde::{Deserialize, Serialize};

/// Runtime isolation mode for a Coven Code session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum RuntimeMode {
#[default]
Local,
HostedReview,
}

impl RuntimeMode {
pub fn is_hosted_review(self) -> bool {
matches!(self, Self::HostedReview)
}
}

/// Settings-backed hosted review configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct HostedReviewConfig {
#[serde(default, skip_serializing_if = "is_false")]
pub enabled: bool,
}

impl HostedReviewConfig {
pub fn is_default(&self) -> bool {
!self.enabled
}
}

/// Tenant/repository identity required before hosted mode may persist
/// durable memory or transcript artifacts into hosted namespaces.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostedReviewScope {
pub tenant_id: String,
pub canonical_repo_identity: String,
}

impl HostedReviewScope {
pub fn new(tenant_id: String, canonical_repo_identity: String) -> Self {
Self {
tenant_id,
canonical_repo_identity,
}
}
}

pub fn env_enables_hosted_review() -> bool {
std::env::var("COVEN_CODE_HOSTED_REVIEW")
.map(|value| is_truthy(&value))
.unwrap_or(false)
}

fn is_truthy(value: &str) -> bool {
!matches!(
value.trim().to_ascii_lowercase().as_str(),
"" | "0" | "false" | "no" | "off"
)
}

fn is_false(value: &bool) -> bool {
!*value
}
Loading