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
25 changes: 25 additions & 0 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,31 @@ Direct hosted auto-persistence requires an explicit trusted policy:
`hostedReview.memorySourceTrust` must meet or exceed
`hostedReview.memoryTrustThreshold`.

Additional hosted invariants:

- Every hosted scope component (tenant, installation, repo id, canonical repo
identity) is validated non-empty before any hosted namespace, transcript
path, or team-sync key is derived; an empty component fails closed instead
of collapsing isolation.
- Hosted loads floor the trust of memory entries that carry no `source`
provenance, so an unattributed entry cannot self-attest a trusted level.
Durable auto-extracted entries record their session/source provenance next
to the trust label.
- Memory deletion and redaction write frontmatter tombstones
(`deleted_at`/`redacted_at`) that propagate through team-memory sync: a
remote tombstone always applies over local content, a local tombstone is
never resurrected by a pull, and a file deleted locally after a sync is not
silently re-created.
- A key with an unresolved team-memory pull conflict is blocked from further
sync until the persisted conflict record under `.conflicts/` is resolved.
- `/review` injects the ids and trust labels of every loaded memory entry into
the review prompt and validates the returned review's memory citations
against that set; unknown citations and memory-dependent findings without
`memory_refs` are surfaced as warnings.
- Headless review results carry a `review.memory` report listing the loaded
memory domains and entries (id, effective trust, visibility, scope) — see
the [headless contract](headless-contract).

---

## Security and permissions
Expand Down
14 changes: 13 additions & 1 deletion docs/headless-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,18 @@ the file MAY be absent.
"findings": [],
"tests_run": [],
"no_findings_reason": "Reviewed the supplied PR file and found no blocking issues.",
"limitations": []
"limitations": [],
"memory": {
"domains_loaded": ["default-branch"],
"entries": [
{
"id": "mem_review_policy",
"trust": "maintainer-approved",
"visibility": "public_review",
"scope": "managed"
}
]
}
},
"exit_reason": null
}
Expand Down Expand Up @@ -191,6 +202,7 @@ the intended code. It is required on every result. Non-review tasks MUST set
| `tests_run` | array | Commands run while reviewing, with `passed`, `failed`, `not_run`, or `unknown` status. |
| `no_findings_reason` | string \| null | File-backed explanation for a clean review. MAY be `null` for degraded/partial output when `evidence_status` and `limitations` explain why a substantive clean-review conclusion was not possible. |
| `limitations` | string[] | Evidence gaps, skipped checks, or other caveats. |
| `memory` | object | Memory audit report: `domains_loaded` (hosted memory domains eligible for this review, e.g. `default-branch`; empty for local runs) and `entries` (every loaded memory entry with its stable `id`, effective `trust` label after hosted caps/floors, optional `visibility`, and load `scope`). Lets the consumer audit which memory inputs could have influenced findings and cross-check `memory_refs` citations. |

Each finding carries `severity`, `file`, optional `line`, `title`, `body`, and
optional `recommendation`. Valid severities are `info`, `low`, `medium`, `high`,
Expand Down
186 changes: 183 additions & 3 deletions src-rust/crates/cli/src/headless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,74 @@ pub struct ReviewResult {
pub tests_run: Vec<ReviewTestRun>,
pub no_findings_reason: Option<String>,
pub limitations: Vec<String>,
/// Memory entries and domains that were loaded for this review, so the
/// artifact records the trust level and provenance scope of every memory
/// input that could have influenced findings.
pub memory: ReviewMemoryUse,
}

/// Memory usage report attached to a review artifact.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
pub struct ReviewMemoryUse {
/// Hosted memory domains that were eligible for this review (e.g.
/// `default-branch`). Empty for local, non-hosted runs.
pub domains_loaded: Vec<String>,
/// Every memory entry loaded into the review context.
pub entries: Vec<ReviewMemoryEntry>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ReviewMemoryEntry {
/// Stable memory id (frontmatter `id` or content hash).
pub id: String,
/// Effective trust after hosted caps/floors (kebab-case label).
pub trust: String,
/// Declared visibility, when present.
pub visibility: Option<String>,
/// Memory scope the entry was loaded from (managed/user/project/local).
pub scope: String,
}

/// Enumerate the memory entries and domains the current configuration loads
/// for a review of `workspace_root`. Uses the same load options as the live
/// context build, so the report matches what the model actually saw.
pub fn collect_review_memory(
workspace_root: &Path,
config: &claurst_core::config::Config,
) -> ReviewMemoryUse {
let options = config.memory_load_options();
let files =
claurst_core::claudemd::load_all_memory_files_with_options(workspace_root, &options);
let entries = files
.iter()
.map(|file| ReviewMemoryEntry {
id: claurst_core::claudemd::memory_id(file),
trust: serde_enum_label(&claurst_core::claudemd::effective_memory_trust(
file, &options,
)),
visibility: file.frontmatter.visibility.map(|v| serde_enum_label(&v)),
scope: serde_enum_label(&file.scope),
})
.collect();
let domains_loaded = if config.hosted_review_enabled() {
// Hosted review currently loads only the default-branch domain;
// security-private and branch domains are excluded by policy.
vec![claurst_core::hosted_review::MemoryDomain::DefaultBranch.path_component()]
} else {
Vec::new()
};
ReviewMemoryUse {
domains_loaded,
entries,
}
}

/// Render a unit enum's serde label (kebab/snake-case string form).
fn serde_enum_label<T: Serialize>(value: &T) -> String {
match serde_json::to_value(value) {
Ok(serde_json::Value::String(label)) => label,
_ => "unknown".to_string(),
}
}

impl ReviewResult {
Expand All @@ -574,13 +642,23 @@ impl ReviewResult {
tests_run: Vec::new(),
no_findings_reason: None,
limitations: Vec::new(),
memory: ReviewMemoryUse::default(),
}
}

pub fn from_brief(
brief: Option<&SessionBrief>,
trace: Option<&ReviewTrace>,
final_text: &str,
) -> Self {
Self::from_brief_with_memory(brief, trace, final_text, ReviewMemoryUse::default())
}

pub fn from_brief_with_memory(
brief: Option<&SessionBrief>,
trace: Option<&ReviewTrace>,
final_text: &str,
memory: ReviewMemoryUse,
) -> Self {
let Some(brief) = brief else {
return Self::none();
Expand Down Expand Up @@ -648,6 +726,7 @@ impl ReviewResult {
tests_run: parsed.tests_run,
no_findings_reason: parsed.no_findings_reason,
limitations,
memory,
}
}
}
Expand Down Expand Up @@ -1322,19 +1401,40 @@ fn classify(
}
}

/// Build the `result.json` envelope and the process exit code from the run.
pub fn build_result(
/// Test convenience: build the result envelope with an empty memory report.
#[cfg(test)]
fn build_result(
brief: Option<&SessionBrief>,
git: &GitSummary,
outcome: RunOutcome,
final_text: &str,
review_trace: Option<&ReviewTrace>,
) -> (ResultEnvelope, i32) {
build_result_with_memory(
brief,
git,
outcome,
final_text,
review_trace,
ReviewMemoryUse::default(),
)
}

/// Build the result envelope with an explicit memory-usage report attached to
/// the review artifact.
pub fn build_result_with_memory(
brief: Option<&SessionBrief>,
git: &GitSummary,
outcome: RunOutcome,
final_text: &str,
review_trace: Option<&ReviewTrace>,
memory: ReviewMemoryUse,
) -> (ResultEnvelope, i32) {
let comment_only = brief.map(SessionBrief::is_comment_only).unwrap_or(false);
let (mut status, mut exit_reason, code) =
classify(outcome, !git.commits.is_empty(), comment_only);

let review = ReviewResult::from_brief(brief, review_trace, final_text);
let review = ReviewResult::from_brief_with_memory(brief, review_trace, final_text, memory);
if review.mode != ReviewMode::None
&& status == Status::Success
&& review.evidence_status != ReviewEvidenceStatus::Complete
Expand Down Expand Up @@ -1543,6 +1643,86 @@ mod tests {
trace.record_tool_end("Read", "", false);
}

// ── Review memory report ────────────────────────────────────────────────

#[test]
fn collect_review_memory_local_lists_project_entries() {
let ws = tempfile::tempdir().unwrap();
std::fs::write(
ws.path().join("AGENTS.md"),
"---\nid: mem_local_fact\ntrust: maintainer_approved\nsource: unit-test\n---\nLocal fact.",
)
.unwrap();
let config = claurst_core::config::Config::default();

let memory = collect_review_memory(ws.path(), &config);

assert!(memory.domains_loaded.is_empty());
let entry = memory
.entries
.iter()
.find(|entry| entry.id == "mem_local_fact")
.expect("project memory entry is reported");
assert_eq!(entry.scope, "project");
assert_eq!(entry.trust, "maintainer-approved");
}

#[test]
fn collect_review_memory_hosted_reports_domain_and_excludes_untrusted() {
let ws = tempfile::tempdir().unwrap();
// A repo file self-attesting high trust must not survive hosted caps.
std::fs::write(
ws.path().join("AGENTS.md"),
"---\nid: mem_attacker\ntrust: maintainer_approved\nsource: repo\n---\nAttacker fact.",
)
.unwrap();
let mut config = claurst_core::config::Config::default();
config.hosted_review.enabled = true;

let memory = collect_review_memory(ws.path(), &config);

assert_eq!(memory.domains_loaded, vec!["default-branch".to_string()]);
assert!(
memory.entries.is_empty(),
"hosted review must not report untrusted repo memory as loaded: {:?}",
memory.entries
);
}

#[test]
fn result_envelope_serializes_review_memory_report() {
let (dir, mut trace) = review_workspace();
record_successful_read(
&mut trace,
dir.path().join("src/support.rs").to_str().unwrap(),
);
let memory = ReviewMemoryUse {
domains_loaded: vec!["default-branch".to_string()],
entries: vec![ReviewMemoryEntry {
id: "mem_policy".to_string(),
trust: "maintainer-approved".to_string(),
visibility: Some("public_review".to_string()),
scope: "managed".to_string(),
}],
};

let (envelope, _) = build_result_with_memory(
Some(&sample_review_brief()),
&GitSummary::default(),
RunOutcome::Completed,
"## Findings\n- [low] src/lib.rs:1 — fine\n\n## Supporting Context Used\n- src/support.rs: checked",
Some(&trace),
memory,
);

let value = serde_json::to_value(&envelope).unwrap();
let memory_value = &value["review"]["memory"];
assert_eq!(memory_value["domains_loaded"][0], "default-branch");
assert_eq!(memory_value["entries"][0]["id"], "mem_policy");
assert_eq!(memory_value["entries"][0]["trust"], "maintainer-approved");
assert_eq!(memory_value["entries"][0]["scope"], "managed");
}

// ── Input conformance ───────────────────────────────────────────────────

#[test]
Expand Down
3 changes: 2 additions & 1 deletion src-rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -919,12 +919,13 @@ async fn main() -> anyhow::Result<()> {
.unwrap_or_else(|| "main".to_string());
let git_summary = headless::collect_git_summary(&cwd, &default_branch);
let (envelope, exit_code) = match &run {
Ok(r) => headless::build_result(
Ok(r) => headless::build_result_with_memory(
github_context.as_ref(),
&git_summary,
r.outcome,
&r.final_text,
Some(&r.review_trace),
headless::collect_review_memory(&cwd, &config),
),
Err(e) => headless::infra_error_result(
github_context.as_ref(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@
}
],
"no_findings_reason": null,
"limitations": []
"limitations": [],
"memory": {
"domains_loaded": [],
"entries": []
}
},
"exit_reason": null
}
25 changes: 25 additions & 0 deletions src-rust/crates/cli/tests/headless_contract/result.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,31 @@
"limitations": {
"type": "array",
"items": { "type": "string" }
},
"memory": {
"type": "object",
"additionalProperties": false,
"required": ["domains_loaded", "entries"],
"properties": {
"domains_loaded": {
"type": "array",
"items": { "type": "string" }
},
"entries": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "trust", "visibility", "scope"],
"properties": {
"id": { "type": "string" },
"trust": { "type": "string" },
"visibility": { "type": ["string", "null"] },
"scope": { "type": "string" }
}
}
}
}
}
},
"allOf": [
Expand Down
Loading