Skip to content
Draft
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
15 changes: 15 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,9 @@ Frontmatter fields:
| `source` | Provenance source kind, for example `manual`, `github_pr`, `github_pr_review`, or `session_memory_extraction`. |
| `source_ref` | Source reference such as `owner/repo#123`, a commit SHA, or another non-secret audit handle. |
| `expires_at` | Optional expiry date in `YYYY-MM-DD` format. Expired hosted memory is ignored. |
| `retention_class` | Optional lifecycle class such as `standard`, `short_lived`, `security`, or `legal_hold`. |
| `redacted_at` | Marks content as redacted. Hosted review keeps the metadata visible but replaces the body with a redaction stub. |
| `deleted_at` | Marks memory as deleted. Hosted review excludes deleted entries from prompt loading. |
| `created_at`, `created_by`, `session_id`, `transcript_ref`, `confidence` | Optional provenance fields for audit and review artifacts. |

Local mode tolerates missing metadata for backward compatibility. Hosted review
Expand All @@ -384,6 +387,18 @@ memory below the configured trust threshold. Tagged hosted memory is injected
with memory ids and provenance metadata; findings that rely on memory should
include those ids in `memory_refs`.

Hosted sync and persistence boundaries run high-confidence secret scanning
before writing or uploading memory. Entries with detected secret patterns are
blocked by default. Logs and review candidates include only pattern labels and
reason codes, not matched secret values. False positives should be handled by
redacting or editing the memory entry before retrying sync.

Hosted team-memory pull is conflict-aware. Local changes are preserved when
both local and remote content changed since the last known server checksum; a
conflict record is written for operator review instead of overwriting local
memory. Hosted team-memory sync also sends tenant, installation, repo, and
domain scope metadata so the server can authorize the full tuple.

### @include directives

AGENTS.md files support `@include` to pull in content from other files:
Expand Down
37 changes: 37 additions & 0 deletions docs/shared/hosted-review-phase-5-pr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Hosted Review Phase 5 PR Notes

## Linked issues

Fixes #102.
Fixes #103.
Fixes #111.
Fixes #109.

## Summary

- Enforces high-confidence secret scanning before hosted session-memory persistence or candidate creation, settings sync upload, team-memory upload, and team-memory pull apply.
- Blocks secret-bearing memory by default and records only scanner labels/reason codes, never matched secret values.
- Adds structured hosted team-memory sync scope with tenant id, installation id, repo id, repo full name, and domain metadata.
- Replaces server-wins team-memory pull application with conflict-aware handling that preserves local changes and writes conflict records for both-changed cases.
- Adds lifecycle metadata support for `retention_class`, `redacted_at`, and `deleted_at`.
- Excludes deleted hosted memory, redacts hosted prompt content for redacted entries, and adds helpers for deleting a hosted memory scope or redacting a memory file.
- Documents retention, redaction, secret scanning, and conflict workflows.

## Test evidence

- `cargo fmt --all -- --check`
- `cargo check --workspace`
- `cargo clippy --workspace --all-targets -- -D warnings`
- `cargo test -p claurst-core --lib claudemd --quiet`
- `cargo test -p claurst-core --lib team_memory_sync --quiet`
- `cargo test -p claurst-core --lib settings_sync --quiet`
- `cargo test -p claurst-core --lib memdir --quiet`
- `cargo test -p claurst-query session_memory --quiet`
- `cargo test -p claurst-core --lib --quiet`
- `cargo test --workspace --quiet`

## Risk notes

- Hosted server authorization still must be enforced server-side; the client now sends structured scope metadata but does not treat client-side path construction as an authorization boundary.
- Conflict records preserve local and remote memory text for operator review, so downstream tooling should apply the same access controls as memory storage.
- Secret scanning is intentionally high-confidence and blocks by default; false positives require operator edit/redaction before retry.
56 changes: 55 additions & 1 deletion src-rust/crates/core/src/claudemd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ pub struct MemoryFrontmatter {
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub retention_class: Option<String>,
#[serde(default)]
pub redacted_at: Option<String>,
#[serde(default)]
pub deleted_at: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub created_by: Option<String>,
Expand Down Expand Up @@ -191,6 +197,13 @@ pub fn parse_frontmatter(content: &str) -> (MemoryFrontmatter, &str) {
"source" => fm.source = Some(strip_frontmatter_value(&val).to_string()),
"source_ref" => fm.source_ref = Some(strip_frontmatter_value(&val).to_string()),
"expires_at" => fm.expires_at = Some(strip_frontmatter_value(&val).to_string()),
"retention_class" => {
fm.retention_class = Some(strip_frontmatter_value(&val).to_string())
}
"redacted_at" => {
fm.redacted_at = Some(strip_frontmatter_value(&val).to_string())
}
"deleted_at" => fm.deleted_at = Some(strip_frontmatter_value(&val).to_string()),
"created_at" => fm.created_at = Some(strip_frontmatter_value(&val).to_string()),
"created_by" => fm.created_by = Some(strip_frontmatter_value(&val).to_string()),
"session_id" => fm.session_id = Some(strip_frontmatter_value(&val).to_string()),
Expand Down Expand Up @@ -356,6 +369,10 @@ pub fn memory_file_allowed_for_options(file: &MemoryFileInfo, options: &MemoryLo
return false;
}

if file.frontmatter.deleted_at.is_some() {
return false;
}

if matches!(
file.frontmatter.visibility,
Some(MemoryVisibility::SecurityPrivate)
Expand Down Expand Up @@ -394,7 +411,11 @@ pub fn memory_id(file: &MemoryFileInfo) -> String {
}

pub fn format_memory_file_for_prompt(file: &MemoryFileInfo, hosted: bool) -> String {
let body = file.content.trim();
let body = if hosted && file.frontmatter.redacted_at.is_some() {
"[REDACTED: memory content removed; retain metadata for audit]"
} else {
file.content.trim()
};
if !hosted {
return body.to_string();
}
Expand Down Expand Up @@ -763,6 +784,39 @@ mod tests {
assert!(files.is_empty());
}

#[test]
fn hosted_review_excludes_deleted_memory() {
let project = tempfile::tempdir().unwrap();
std::fs::write(
project.path().join("AGENTS.md"),
"---\ntrust: maintainer_approved\nvisibility: public_review\ndeleted_at: 2026-01-01T00:00:00Z\n---\ndeleted memory",
)
.unwrap();

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

assert!(files.is_empty());
}

#[test]
fn hosted_review_redacts_memory_content_in_prompt() {
let project = tempfile::tempdir().unwrap();
std::fs::write(
project.path().join("AGENTS.md"),
"---\nid: mem_redacted\ntrust: maintainer_approved\nvisibility: public_review\nredacted_at: 2026-01-01T00:00:00Z\n---\noriginal sensitive detail",
)
.unwrap();

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

assert!(prompt.contains("id=\"mem_redacted\""));
assert!(prompt.contains("[REDACTED: memory content removed"));
assert!(!prompt.contains("original sensitive detail"));
}

#[test]
fn hosted_review_excludes_security_private_memory_by_default() {
let project = tempfile::tempdir().unwrap();
Expand Down
62 changes: 62 additions & 0 deletions src-rust/crates/core/src/memdir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,26 @@ fn hosted_memory_path(scope: &HostedReviewScope) -> PathBuf {
.join("memory")
}

pub fn hosted_memory_path_for_scope(scope: &HostedReviewScope) -> PathBuf {
hosted_memory_path(scope)
}

pub fn delete_hosted_memory_for_scope(scope: &HostedReviewScope) -> std::io::Result<()> {
let path = hosted_memory_path(scope);
if path.exists() {
std::fs::remove_dir_all(path)?;
}
Ok(())
}

pub fn redact_memory_file(path: &Path, reason: &str) -> std::io::Result<()> {
let timestamp = chrono::Utc::now().to_rfc3339();
let stub = format!(
"---\nredacted_at: {timestamp}\nretention_class: security\nsource: redaction\n---\n\n[REDACTED: {reason}]\n"
);
std::fs::write(path, stub)
}

/// Sanitize an arbitrary string into a directory-name-safe component.
/// Matches `sanitizePath` used inside `getAutoMemPath` in `paths.ts`.
pub fn sanitize_path_component(s: &str) -> String {
Expand Down Expand Up @@ -1029,4 +1049,46 @@ mod tests {
assert_ne!(first, branch);
assert!(branch.to_string_lossy().contains("branch-feature_review"));
}

#[test]
fn hosted_memory_delete_removes_scope_directory() {
let home = tempfile::tempdir().unwrap();
let _lock = crate::coven_shared::COVEN_HOME_ENV_LOCK
.lock()
.unwrap_or_else(|err| err.into_inner());
let original_test_home = std::env::var("COVEN_CODE_TEST_HOME").ok();
std::env::set_var("COVEN_CODE_TEST_HOME", home.path());
let scope = crate::hosted_review::HostedReviewScope::new(
"tenant-delete".to_string(),
"install-delete".to_string(),
"repo-delete".to_string(),
"OpenCoven/coven-code".to_string(),
);
let path = hosted_memory_path_for_scope(&scope);
std::fs::create_dir_all(&path).unwrap();
std::fs::write(path.join("MEMORY.md"), "delete me").unwrap();

delete_hosted_memory_for_scope(&scope).unwrap();

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

assert!(!path.exists());
}

#[test]
fn redact_memory_file_preserves_audit_stub_without_original_content() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("MEMORY.md");
std::fs::write(&path, "secret incident detail").unwrap();

redact_memory_file(&path, "operator request").unwrap();

let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("redacted_at:"));
assert!(content.contains("[REDACTED: operator request]"));
assert!(!content.contains("secret incident detail"));
}
}
42 changes: 42 additions & 0 deletions src-rust/crates/core/src/settings_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// and values are the UTF-8 file contents (JSON or Markdown).

use crate::hosted_review::{hosted_project_id, HostedReviewScope};
use crate::team_memory_sync::scan_for_secrets;
use anyhow::Result;
use serde::Deserialize;
use serde_json::Value;
Expand Down Expand Up @@ -288,6 +289,8 @@ impl SettingsSyncManager {
///
/// Compares with existing remote entries and only uploads changed keys.
pub async fn upload(&self, local_entries: HashMap<String, String>) -> Result<()> {
let local_entries = filter_entries_with_secrets(local_entries, "Settings sync");

// Fetch current remote state for diff
let remote_entries = match self.download().await? {
Some(data) => data.memory_files,
Expand Down Expand Up @@ -352,6 +355,31 @@ impl SettingsSyncManager {
}
}

fn filter_entries_with_secrets(
entries: HashMap<String, String>,
context: &str,
) -> HashMap<String, String> {
entries
.into_iter()
.filter_map(|(key, value)| {
let secrets = scan_for_secrets(&value);
if secrets.is_empty() {
return Some((key, value));
}

let labels: Vec<&str> = secrets.iter().map(|m| m.label.as_str()).collect();
warn!(
"{}: blocking {:?} from upload: detected {} ({} secret pattern(s))",
context,
key,
labels.join(", "),
labels.len(),
);
None
})
.collect()
}

// ---------------------------------------------------------------------------
// Apply result
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -543,6 +571,20 @@ mod tests {
assert!(data.memory_files.is_empty());
}

#[test]
fn filter_entries_with_secrets_blocks_secret_values() {
let mut entries = HashMap::new();
let secret = format!("ghp_{}", "A".repeat(36));
entries.insert(SYNC_KEY_USER_MEMORY.to_string(), format!("token={secret}"));
entries.insert("safe.md".to_string(), "# Safe".to_string());

let filtered = filter_entries_with_secrets(entries, "test");

assert!(filtered.contains_key("safe.md"));
assert!(!filtered.contains_key(SYNC_KEY_USER_MEMORY));
assert!(!filtered.values().any(|value| value.contains(&secret)));
}

#[test]
fn test_retry_delay_progression() {
assert_eq!(retry_delay(1), Duration::from_secs(1));
Expand Down
Loading