Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
fba7bda
test(zotero): require aggregate steward progress
seonghobae Sep 4, 2026
bace2ac
feat(zotero): report steward review progress
seonghobae Sep 4, 2026
db980fc
fix(zotero): preserve finalization error contract
seonghobae Sep 4, 2026
18af5ad
fix(zotero): keep empty campaigns incomplete
seonghobae Sep 4, 2026
973cf7c
docs(zotero): define steward progress evidence
seonghobae Sep 4, 2026
bd55248
test(zotero): cover progress snapshot rejection
seonghobae Sep 4, 2026
9105db6
Merge offline finalization security into review progress
seonghobae Sep 4, 2026
453e365
Test review progress artifact identity
seonghobae Sep 4, 2026
9d45cc9
Merge remote-tracking branch 'origin/autoresearch/zotero-offline-fina…
seonghobae Sep 4, 2026
a5ebe71
docs(zotero): include review progress parser mode
seonghobae Sep 4, 2026
4e470c5
docs(zotero): record live review campaign checkpoint
seonghobae Sep 4, 2026
2b80c2e
docs(zotero): separate review coverage from approval KPI
seonghobae Sep 4, 2026
9ee2447
Merge remote-tracking branch 'origin/autoresearch/zotero-report-round…
seonghobae Sep 4, 2026
f3f530f
test(zotero): require no-follow private input open
seonghobae Sep 4, 2026
12ea86a
docs(security): record no-follow artifact boundary
seonghobae Sep 4, 2026
7b1320e
Merge remote-tracking branch 'origin/autoresearch/zotero-offline-fina…
seonghobae Sep 4, 2026
4772ae9
docs(gap): bind campaign to parent provenance
seonghobae Sep 4, 2026
9733d28
test(zotero): reproduce final-component symlink swap
seonghobae Sep 4, 2026
7a8eb82
Merge remote-tracking branch 'origin/autoresearch/zotero-review-progr…
seonghobae Sep 4, 2026
7ccbbbe
fix(zotero): refuse symlink artifact opens
seonghobae Sep 4, 2026
80ba600
docs(security): record no-follow repair evidence
seonghobae Sep 4, 2026
5b92e7d
style(zotero): format no-follow regression tests
seonghobae Sep 4, 2026
aca4b5c
test(zotero): keep no-follow boundary in coverage
seonghobae Sep 4, 2026
0a50e02
test(zotero): cover no-follow input boundary
seonghobae Sep 4, 2026
b1d391c
refactor(zotero): retain metadata open errors
seonghobae Sep 4, 2026
092d702
test(zotero): require private artifact helpers in owned coverage
seonghobae Sep 4, 2026
2dace39
test(zotero): pin validated parent path before private reads
seonghobae Sep 4, 2026
b06f54a
fix(zotero): pin validated private artifact parent
seonghobae Sep 4, 2026
044da14
fix(zotero): cover private artifact helpers
seonghobae Sep 4, 2026
c36f2ba
test(zotero): cover private write failure
seonghobae Sep 4, 2026
9f83c28
test(zotero): reject missing private artifact filename
seonghobae Sep 4, 2026
5560484
Merge current offline-finalization parent into review progress
seonghobae Sep 4, 2026
438f32b
Merge repaired write receipt evidence into zotero-review-progress
seonghobae Sep 4, 2026
54307bd
Merge current receipt repair parent into zotero-review-progress
seonghobae Sep 4, 2026
331c90c
Merge remote-tracking branch 'origin/autoresearch/zotero-offline-fina…
seonghobae Sep 4, 2026
172cd1b
merge(zotero): adopt current offline-finalization parent
seonghobae Sep 4, 2026
ddd0e4c
merge(zotero): adopt current offline-finalization parent and gap base…
seonghobae Sep 5, 2026
c6e2519
merge(research): inherit verified source and proposal approval binding
seonghobae Sep 5, 2026
509ce7d
test(research): adopt captured-source progress fixtures
seonghobae Sep 5, 2026
850b7fd
merge(research): inherit canonical local transport repairs into PR #30
seonghobae Sep 5, 2026
b6645a5
merge(research): inherit deterministic transport framing regression i…
seonghobae Sep 5, 2026
88d4931
Merge verified private artifact boundary repair into PR 30
seonghobae Sep 5, 2026
a43ddce
merge(zotero): propagate validated approval ordering through PR 30
seonghobae Sep 5, 2026
a11e889
merge(research): inherit bounded metadata reads into PR #30
seonghobae Sep 6, 2026
7251238
merge: preserve repaired offline scope in review progress
seonghobae Sep 6, 2026
4099434
test(zotero): expose progress binding and pending scope gaps
seonghobae Sep 6, 2026
bee32f4
fix(zotero): bind progress to content and unresolved source scope
seonghobae Sep 6, 2026
b2b0ef4
test(zotero): keep filled campaigns pending unresolved sources
seonghobae Sep 6, 2026
236a9db
docs(zotero): define content-bound review preparation progress
seonghobae Sep 6, 2026
ee1ac99
docs(zotero): record exact progress scope verification
seonghobae Sep 6, 2026
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: 10 additions & 1 deletion THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ Source artifacts, imported ontologies, provider responses, model outputs, web-re
7. tenant/workspace evidence disclosure;
8. SSRF, DNS rebinding, unsafe redirects, or unbounded external retrieval;
9. dependency/provider compromise or unexpected retention;
10. write-back without reviewed before/after/rollback evidence and exact preconditions.
10. write-back without reviewed before/after/rollback evidence and exact preconditions;
11. same-host filesystem races replacing a checked owner-only review artifact path with a symlink before the file descriptor is opened.

## Zotero 10+ Local API transport boundary

Expand Down Expand Up @@ -64,6 +65,14 @@ Neither path may reinterpret `Zotero-Server-ID` as cryptographic peer authentica
- attachments and bibliographic source records are not deleted by classification write-back;
- descendant integration evidence never back-proves an unresolved predecessor contract.

## Owner-only review artifact filesystem boundary

Saved report, worksheet, approval, progress, and golden-set artifacts are sensitive local review material. Path policy alone is not the security boundary. A direct-temp-child path may be checked as a regular non-symlink and still be replaced by a symlink before a later symlink-following open.

The opened file descriptor must therefore be obtained with a Unix final-component no-follow primitive such as `O_NOFOLLOW` or an equivalent safe abstraction. After open, ConceptWeave still verifies that the opened device/inode matches the checked regular file, link count is one, mode is exactly `0600`, and the bounded-read contract is satisfied. A second pathname check is not an equivalent repair because it leaves another check/open race window.

PR #30 commit `9733d28` reproduced the inode-preserving final-component symlink swap and left the no-follow contract RED. Commit `7ccbbbe` repairs the shared input-open helper with Unix `O_NOFOLLOW`; focused security and artifact-identity tests then pass. Offline review/finalization remains acceptance-gated until this repair has terminal protected checks and independent approval on one unchanged exact head.

## Release gate

A capability is not release-ready while a valid security finding lacks a deterministic test or equivalent machine-verifiable contract, while required exact-head checks are non-terminal, or while the implemented transport cannot satisfy the advertised security claim. Documentation must describe residual risk without upgrading provider guarantees by inference.
99 changes: 90 additions & 9 deletions crates/conceptweave-zotero/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,30 @@ pub struct StewardReviewWorksheet {
pub decisions: Vec<StewardReviewDecision>,
}

/// Aggregate-only checkpoint for a human steward review campaign.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct StewardReviewProgress {
/// Zotero library revision bound to the original report.
pub library_version: u64,
/// Classifier revision whose proposals are being reviewed.
pub rule_revision: String,
/// Opaque immutable snapshot identity.
pub snapshot_digest: String,
/// Opaque binding to the proposals and retained metadata used for these counts.
pub proposal_digest: String,
/// Number of bibliographic decisions required for completion.
pub total_count: usize,
/// Number of non-abstention decisions supplied by a steward.
pub decided_count: usize,
/// Number of decisions still blank.
pub remaining_count: usize,
/// Number of source records whose ancestry still requires resolution.
pub pending_source_count: usize,
/// Whether nonempty decision slots are filled and no source remains pending.
/// This is local preparation status, never independent approval or an applied write.
pub complete: bool,
}

/// A classification report cannot safely produce a review worksheet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorksheetError {
Expand Down Expand Up @@ -1193,6 +1217,68 @@ pub fn build_steward_review_worksheet(
})
}

/// Validates an in-progress worksheet and returns privacy-safe aggregate progress.
pub fn assess_steward_review_progress(
report: &ClassificationReport,
worksheet: &StewardReviewWorksheet,
) -> Result<StewardReviewProgress, WorksheetError> {
let expected = build_steward_review_worksheet(report)?;
validate_steward_review_worksheet_against(&expected, worksheet)?;
if worksheet
.decisions
.iter()
.any(|decision| decision.reviewed_disposition == Some(Disposition::NeedsStewardReview))
{
return Err(WorksheetError::InvalidReport);
}
let decided_count = worksheet
.decisions
.iter()
.filter(|decision| decision.reviewed_disposition.is_some())
.count();
let total_count = worksheet.decisions.len();
let remaining_count = total_count - decided_count;
Ok(StewardReviewProgress {
library_version: worksheet.library_version,
rule_revision: worksheet.rule_revision.clone(),
snapshot_digest: worksheet.snapshot_digest.clone(),
proposal_digest: worksheet.proposal_digest.clone(),
total_count,
decided_count,
remaining_count,
pending_source_count: report.pending_source_item_keys.len(),
complete: total_count > 0
&& remaining_count == 0
&& report.pending_source_item_keys.is_empty(),
})
}

fn validate_steward_review_worksheet_against(
expected: &StewardReviewWorksheet,
worksheet: &StewardReviewWorksheet,
) -> Result<(), WorksheetError> {
if worksheet.library_version != expected.library_version
|| worksheet.rule_revision != expected.rule_revision
|| worksheet.snapshot_digest != expected.snapshot_digest
|| worksheet.proposal_digest != expected.proposal_digest
|| worksheet.snapshot_items != expected.snapshot_items
|| worksheet.decisions.len() != expected.decisions.len()
|| worksheet
.decisions
.iter()
.zip(&expected.decisions)
.any(|(decision, expected)| {
decision.item_key != expected.item_key
|| decision.item_version != expected.item_version
|| decision.proposed_disposition != expected.proposed_disposition
|| decision.abstention_reason != expected.abstention_reason
})
{
return Err(WorksheetError::InvalidReport);
}
Ok(())
}

/// One steward-reviewed expected disposition in a local golden set.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct GoldenLabel {
Expand Down Expand Up @@ -1293,16 +1379,11 @@ pub fn reviewed_golden_set_from_worksheet(
{
return Err(EvaluationError::SnapshotMismatch);
}

if validate_steward_review_worksheet_against(&expected, worksheet).is_err() {
return Err(EvaluationError::InvalidReview);
}
let mut labels = Vec::with_capacity(worksheet.decisions.len());
for (decision, expected_decision) in worksheet.decisions.iter().zip(expected.decisions) {
if decision.item_key != expected_decision.item_key
|| decision.item_version != expected_decision.item_version
|| decision.proposed_disposition != expected_decision.proposed_disposition
|| decision.abstention_reason != expected_decision.abstention_reason
{
return Err(EvaluationError::InvalidReview);
}
for decision in &worksheet.decisions {
let expected_disposition = decision
.reviewed_disposition
.ok_or(EvaluationError::IncompleteReview)?;
Expand Down
94 changes: 86 additions & 8 deletions crates/conceptweave-zotero/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

use conceptweave_zotero::{
ClassificationReport, GoldenSetApproval, StewardReviewWorksheet,
build_steward_review_worksheet, read_local_snapshot, reviewed_golden_set_from_worksheet,
assess_steward_review_progress, build_steward_review_worksheet, read_local_snapshot,
reviewed_golden_set_from_worksheet,
};
use serde::de::DeserializeOwned;
use std::collections::BTreeSet;
Expand All @@ -12,7 +13,7 @@ use std::fs::{self, File, OpenOptions};
use std::io::{self, BufWriter, Read, Write};
use std::path::{Path, PathBuf};

const USAGE: &str = "usage: conceptweave-zotero /tmp/REPORT.json | --worksheet /tmp/REPORT.json /tmp/WORKSHEET.json | --finalize /tmp/REPORT.json /tmp/WORKSHEET.json /tmp/APPROVAL.json /tmp/GOLDEN.json";
const USAGE: &str = "usage: conceptweave-zotero /tmp/REPORT.json | --worksheet /tmp/REPORT.json /tmp/WORKSHEET.json | --review-progress /tmp/REPORT.json /tmp/WORKSHEET.json /tmp/PROGRESS.json | --finalize /tmp/REPORT.json /tmp/WORKSHEET.json /tmp/APPROVAL.json /tmp/GOLDEN.json";
const MAX_ARTIFACT_BYTES: u64 = 16 * 1024 * 1024;

#[derive(Debug, PartialEq, Eq)]
Expand All @@ -22,6 +23,11 @@ enum OutputRequest {
report: String,
worksheet: String,
},
ReviewProgress {
report: String,
worksheet: String,
output: String,
},
Finalize {
report: String,
worksheet: String,
Expand All @@ -30,7 +36,7 @@ enum OutputRequest {
},
}

/// Parses one mutually exclusive report, worksheet, or finalization request.
/// Parses one mutually exclusive report, worksheet, review-progress, or finalization request.
fn parse_output_request<I, S>(args: I) -> Result<OutputRequest, &'static str>
where
I: IntoIterator<Item = S>,
Expand All @@ -49,6 +55,24 @@ where
return Err("report and worksheet output paths must differ");
}
OutputRequest::Worksheet { report, worksheet }
} else if first == "--review-progress" {
let report = args
.next()
.ok_or("--review-progress requires three artifact paths")?;
let worksheet = args
.next()
.ok_or("--review-progress requires three artifact paths")?;
let output = args
.next()
.ok_or("--review-progress requires three artifact paths")?;
if BTreeSet::from([report.as_str(), worksheet.as_str(), output.as_str()]).len() != 3 {
return Err("review progress artifact paths must differ");
}
OutputRequest::ReviewProgress {
report,
worksheet,
output,
}
} else if first == "--finalize" {
let report = args
.next()
Expand Down Expand Up @@ -142,7 +166,6 @@ fn read_private_json<T: DeserializeOwned>(raw: &str) -> io::Result<(T, ArtifactI
}
}

#[cfg_attr(coverage_nightly, coverage(off))]
/// Opens without following a symlink or waiting for a raced FIFO, then reads handle metadata.
fn open_with_metadata(path: &Path) -> io::Result<(File, fs::Metadata)> {
#[cfg(unix)]
Expand All @@ -153,8 +176,7 @@ fn open_with_metadata(path: &Path) -> io::Result<(File, fs::Metadata)> {
#[cfg(unix)]
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
let file = options.open(path)?;
Comment thread
seonghobae marked this conversation as resolved.
let metadata = file.metadata()?;
Ok((file, metadata))
file.metadata().map(|metadata| (file, metadata))
}

#[cfg(unix)]
Expand Down Expand Up @@ -237,7 +259,6 @@ fn write_private_output(path: &Path, content: &[u8]) -> io::Result<()> {
write_private_output_with(path, content, write_all_and_flush)
}

#[cfg_attr(coverage_nightly, coverage(off))]
/// Writes and flushes the complete serialized artifact.
fn write_all_and_flush(writer: &mut BufWriter<File>, content: &[u8]) -> io::Result<()> {
writer.write_all(content)?;
Expand All @@ -258,7 +279,6 @@ fn write_private_output_with(
result
}

#[cfg_attr(coverage_nightly, coverage(off))]
/// Returns canonical directories in which a sensitive report may be created.
fn allowed_output_parents() -> Vec<PathBuf> {
let mut parents = vec![
Expand Down Expand Up @@ -371,6 +391,26 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
write_private_output(&report_output, &report_content)?;
write_private_output(&worksheet_output, &worksheet_content)?;
}
OutputRequest::ReviewProgress {
report,
worksheet,
output,
} => {
let output = validate_output_path(&output)?;
let (report, report_identity): (ClassificationReport, _) =
read_private_json(&report).map_err(|error| label_input("report", error))?;
let (worksheet, worksheet_identity): (StewardReviewWorksheet, _) =
read_private_json(&worksheet).map_err(|error| label_input("worksheet", error))?;
if report_identity == worksheet_identity {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"review progress inputs must be distinct files",
)
.into());
}
let progress = assess_steward_review_progress(&report, &worksheet)?;
write_private_output(&output, &serde_json::to_vec_pretty(&progress)?)?;
}
OutputRequest::Finalize {
report,
worksheet,
Expand Down Expand Up @@ -516,6 +556,37 @@ mod tests {
);
}

#[test]
fn review_progress_mode_requires_three_distinct_artifact_paths() {
let report = "/tmp/report.json";
let worksheet = "/tmp/worksheet.json";
let output = "/tmp/progress.json";
assert_eq!(
parse_output_request(vec!["--review-progress", report, worksheet, output]),
Ok(OutputRequest::ReviewProgress {
report: report.to_owned(),
worksheet: worksheet.to_owned(),
output: output.to_owned(),
})
);
assert!(parse_output_request(vec!["--review-progress"]).is_err());
assert!(parse_output_request(vec!["--review-progress", report]).is_err());
assert!(parse_output_request(vec!["--review-progress", report, worksheet]).is_err());
assert!(
parse_output_request(vec!["--review-progress", report, worksheet, report]).is_err()
);
assert!(
parse_output_request(vec![
"--review-progress",
report,
worksheet,
output,
"extra"
])
.is_err()
);
}

#[cfg(unix)]
#[test]
fn private_json_input_is_owner_only_regular_bounded_and_valid() {
Expand All @@ -534,6 +605,7 @@ mod tests {
assert_eq!(parsed["accepted"], true);
assert!(read_private_json::<serde_json::Value>("relative.json").is_err());
assert!(read_private_json::<serde_json::Value>("/").is_err());
assert!(read_private_json::<serde_json::Value>("/tmp/..").is_err());
assert!(
read_private_json::<serde_json::Value>(
unique_temp_path("missing-input").to_str().unwrap()
Expand Down Expand Up @@ -824,6 +896,12 @@ mod tests {
assert_eq!(fs::read(&output).unwrap(), b"complete");
assert!(write_private_output(&output, b"replacement").is_err());
fs::remove_file(output).unwrap();

let read_only = unique_temp_path("read-only-writer");
fs::write(&read_only, b"input").unwrap();
let mut writer = BufWriter::with_capacity(1, File::open(&read_only).unwrap());
assert!(write_all_and_flush(&mut writer, b"content").is_err());
fs::remove_file(read_only).unwrap();
}

fn unique_temp_path(suffix: &str) -> PathBuf {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use std::os::unix::fs::PermissionsExt;
use std::process::Command;

#[test]
fn finalization_rejects_distinct_path_spellings_for_one_input_file() {
fn artifact_commands_reject_distinct_path_spellings_for_one_input_file() {
let item = ZoteroItem {
source_record: None,
key: "ITEM".into(),
Expand Down Expand Up @@ -91,11 +91,24 @@ fn finalization_rejects_distinct_path_spellings_for_one_input_file() {
])
.status()
.unwrap();
let progress_status = Command::new(env!("CARGO_BIN_EXE_conceptweave-zotero"))
.args([
"--review-progress",
&report_path,
&worksheet_path,
output.to_str().unwrap(),
])
.status()
.unwrap();

let _ = fs::remove_file(&input);
let _ = fs::remove_file(&output);
assert!(
!status.success(),
"finalization must reject three path spellings that resolve to one input artifact"
);
assert!(
!progress_status.success(),
"progress must reject two path spellings that resolve to one input artifact"
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#[test]
fn private_artifact_helpers_are_not_excluded_from_owned_coverage() {
let source = include_str!("../src/main.rs");
for helper in ["fn write_all_and_flush", "fn allowed_output_parents"] {
let position = source
.find(helper)
.unwrap_or_else(|| panic!("missing production helper: {helper}"));
let prefix = &source[..position];
let window_start = prefix.len().saturating_sub(240);
let declaration_context = &prefix[window_start..];
assert!(
!declaration_context.contains("#[cfg_attr(coverage_nightly, coverage(off))]"),
"{helper} must remain inside owned coverage rather than bypass the 100% production gate"
);
}
}
Loading