diff --git a/CHANGELOG.md b/CHANGELOG.md index ce01ded9..836b76bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ All notable changes to ConceptWeave are documented here. - Standards and research doctoring covering stable W3C ontology standards, 2026 RDF/SHACL work in progress, Apache Ossie, and recent LLM ontology-engineering research. - Read-only delayed reconciliation receipts for indeterminate Zotero rollback operations. - Minimal, nonduplicated local abstract context for Zotero items that require steward classification. +- Owner-only file permissions for sensitive local Zotero classification reports. ### Security diff --git a/README.md b/README.md index 046d2544..9d12d774 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,11 @@ With Zotero running locally: cargo +1.98.0 run --bin conceptweave-zotero -- /tmp/conceptweave-zotero-classification.json ``` -The command reads one stable library-version snapshot and creates a local, reviewable JSON report. Output is restricted to a new direct child of canonical `/tmp` or the system temporary directory, and the command never changes Zotero records. +The command reads one stable library-version snapshot and creates a local, reviewable JSON report. On Unix, output is restricted to a new owner-only (`0600`) direct child of canonical `/tmp` or the system temporary directory; the CLI fails closed on other platforms. The command never changes Zotero records. + +If file-permission setup fails, the command stops before writing report content. +An empty file may remain; inspect it before removing it. The command does not +delete a pathname that another process may have replaced. [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ContextualWisdomLab/ConceptWeave) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index dd89c289..8924fb18 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -3,20 +3,24 @@ use conceptweave_zotero::read_local_snapshot; use std::env; -use std::fs::{self, OpenOptions}; +use std::fs::{self, File, OpenOptions}; use std::io::{self, BufWriter, Write}; use std::path::{Path, PathBuf}; #[cfg_attr(coverage_nightly, coverage(off))] -fn allowed_output_parents() -> [PathBuf; 2] { - [ +/// Returns canonical directories in which a sensitive report may be created. +fn allowed_output_parents() -> Vec { + let mut parents = vec![ env::temp_dir() .canonicalize() .expect("system temporary directory must exist"), - Path::new("/tmp").canonicalize().expect("/tmp must exist"), - ] + ]; + #[cfg(unix)] + parents.push(Path::new("/tmp").canonicalize().expect("/tmp must exist")); + parents } +/// Validates that a report path is a new direct child of an allowed temp directory. fn validate_output_path(raw: &str) -> io::Result { let path = PathBuf::from(raw); if !path.is_absolute() { @@ -37,16 +41,61 @@ fn validate_output_path(raw: &str) -> io::Result { "report output must be a direct child of the system temp directory", )); } - if fs::symlink_metadata(&path).is_ok() { + let file_name = path.file_name().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "report output has no file name", + ) + })?; + let validated_path = resolved_parent.join(file_name); + if fs::symlink_metadata(&validated_path).is_ok() { return Err(io::Error::new( io::ErrorKind::AlreadyExists, "report output must not already exist or be a symlink", )); } - Ok(path) + Ok(validated_path) +} + +/// Creates a new sensitive report file or fails closed on unsupported platforms. +fn create_report_file(path: &Path) -> io::Result { + #[cfg(not(unix))] + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "private report creation requires a Unix platform", + )); + + #[cfg(unix)] + { + create_report_file_with(path, set_owner_only_permissions) + } +} + +#[cfg(unix)] +/// Restores exact owner-only permissions after process umask application. +fn set_owner_only_permissions(file: &File) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + file.set_permissions(fs::Permissions::from_mode(0o600)) +} + +#[cfg(unix)] +/// Creates a private file; failure leaves an empty file rather than unlinking a raced path. +fn create_report_file_with( + path: &Path, + set_permissions: fn(&File) -> io::Result<()>, +) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + let file = options.mode(0o600).open(path)?; + set_permissions(&file)?; + Ok(file) } #[cfg_attr(coverage_nightly, coverage(off))] +/// Reads one Zotero snapshot and writes its sensitive local proposal report. fn main() -> Result<(), Box> { let output = env::args() .nth(1) @@ -56,10 +105,7 @@ fn main() -> Result<(), Box> { if report.zotero_version.starts_with("9.") { eprintln!("Zotero 9 Local API is read-only; writing a local proposal report only"); } - let file = OpenOptions::new() - .write(true) - .create_new(true) - .open(output)?; + let file = create_report_file(&output)?; let mut writer = BufWriter::new(file); serde_json::to_writer_pretty(&mut writer, &report)?; writer.flush()?; @@ -84,10 +130,19 @@ mod tests { assert_eq!( validate_output_path(allowed.to_str().unwrap()).unwrap(), allowed + .parent() + .unwrap() + .canonicalize() + .unwrap() + .join(allowed.file_name().unwrap()) ); assert!(validate_output_path("relative.json").is_err()); assert!(validate_output_path("/").is_err()); + let missing_name = + validate_output_path(env::temp_dir().join("..").to_str().unwrap()).unwrap_err(); + assert_eq!(missing_name.kind(), io::ErrorKind::InvalidInput); + assert_eq!(missing_name.to_string(), "report output has no file name"); assert!(validate_output_path("/tmp/missing-directory/report.json").is_err()); assert!( validate_output_path( @@ -100,12 +155,18 @@ mod tests { .is_err() ); - let conventional = Path::new("/tmp").join(format!( - "conceptweave-zotero-{}-conventional.json", - std::process::id() - )); - let _ = fs::remove_file(&conventional); - assert!(validate_output_path(conventional.to_str().unwrap()).is_ok()); + #[cfg(unix)] + { + let conventional = Path::new("/tmp").join(format!( + "conceptweave-zotero-{}-conventional.json", + std::process::id() + )); + let _ = fs::remove_file(&conventional); + assert!(validate_output_path(conventional.to_str().unwrap()).is_ok()); + } + + #[cfg(not(unix))] + assert!(validate_output_path("/tmp/conceptweave-zotero.json").is_err()); let nested_dir = env::temp_dir().join(format!("conceptweave-zotero-{}-nested", std::process::id())); @@ -134,4 +195,89 @@ mod tests { fs::remove_file(link).unwrap(); fs::remove_file(target).unwrap(); } + + #[cfg(unix)] + #[test] + fn report_output_is_owner_readable_and_writable_only() { + use std::os::unix::fs::PermissionsExt; + + let output = unique_temp_path("private"); + let _ = fs::remove_file(&output); + let file = create_report_file(&output).unwrap(); + let mode = file.metadata().unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + drop(file); + fs::remove_file(output).unwrap(); + + let existing = unique_temp_path("private-existing"); + let _ = fs::remove_file(&existing); + fs::write(&existing, b"existing").unwrap(); + assert!(create_report_file(&existing).is_err()); + fs::remove_file(existing).unwrap(); + + let rejected = unique_temp_path("private-permission-error"); + let _ = fs::remove_file(&rejected); + let error = create_report_file_with(&rejected, |_| { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "injected permission failure", + )) + }) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert_eq!(fs::metadata(&rejected).unwrap().len(), 0); + fs::remove_file(rejected).unwrap(); + } + + #[cfg(unix)] + #[test] + fn permission_failure_preserves_a_replacement_at_the_output_path() { + use std::os::unix::fs::PermissionsExt; + let output = unique_temp_path("permission-replaced"); + let retained = unique_temp_path("permission-original"); + assert!(!output.exists()); + assert!(!retained.exists()); + let error = create_report_file_with(&output, |file| { + assert_eq!(file.metadata()?.permissions().mode() & 0o077, 0); + fs::rename( + unique_temp_path("permission-replaced"), + unique_temp_path("permission-original"), + )?; + let mut replacement = OpenOptions::new() + .write(true) + .create_new(true) + .open(unique_temp_path("permission-replaced"))?; + replacement.write_all(b"unrelated replacement")?; + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "injected failure", + )) + }) + .unwrap_err(); + let preserved = fs::read(&output).ok(); + let _ = fs::remove_file(&output); + fs::remove_file(retained).unwrap(); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert_eq!( + preserved.as_deref(), + Some(b"unrelated replacement".as_slice()) + ); + } + + #[cfg(unix)] + #[test] + fn report_output_returns_the_checked_canonical_parent() { + let output = Path::new("/tmp").join(format!( + "conceptweave-zotero-{}-canonical.json", + std::process::id() + )); + let expected = Path::new("/tmp") + .canonicalize() + .unwrap() + .join(output.file_name().unwrap()); + assert_eq!( + validate_output_path(output.to_str().unwrap()).unwrap(), + expected + ); + } } diff --git a/docs/TRD.md b/docs/TRD.md index 83b1d44c..07ffe2c9 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -109,7 +109,7 @@ A successful classification report carries an `audit_summary` whose snapshot, bi The local report can contain titles, tags, matched metadata, and abstention abstracts. It is sensitive steward-review material, remains outside the repository, and is not a publication artifact. -The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. Every receipt copies the plan's review, authority, server, Zotero version, library, rule, snapshot and proposal coordinates; dry-run reports every operation as not attempted and makes no Local API call. Execute mode preflights every item before the first write, advances the library precondition only from a directly verified write response, stops on the first adapter or response failure, and re-reads that item through the same boundary as observation only. Failed writes remain indeterminate regardless of observed metadata; no inverse is issued for them. Prior directly verified operations retain their inverse coordinates. The API key remains adapter-owned and absent from serializable structures. +The report is local JSON and contains proposals rather than governance decisions. On supported Unix platforms, CLI output is restricted to a new owner-readable/writable (`0600`) direct child of canonical `/tmp` or the operating system temporary directory; exact permissions are restored after umask application, and other platforms fail closed. Relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. Every receipt copies the plan's review, authority, server, Zotero version, library, rule, snapshot and proposal coordinates; dry-run reports every operation as not attempted and makes no Local API call. Execute mode preflights every item before the first write, advances the library precondition only from a directly verified write response, stops on the first adapter or response failure, and re-reads that item through the same boundary as observation only. Failed writes remain indeterminate regardless of observed metadata; no inverse is issued for them. Prior directly verified operations retain their inverse coordinates. The API key remains adapter-owned and absent from serializable structures. PR #20 retains its rollback core and adapter: mixed-server rejection precedes reads; complete current-state checks precede inverse writes; only directly verified responses advance the library version. Every failed or invalid inverse response now remains indeterminate, retaining the complete operation, exact submitted request (including its library precondition), and optional complete readback. Matching restored or unchanged metadata does not prove causal completion or termination, and the failed inverse is absent from remaining work. Earlier directly verified restorations remain recorded; remaining operations are untouched only, not automatic retry authority. The operation-slice API still lacks original-write scope and independent authority; authoritative consumer adoption remains an open gate, including empty-slice and delayed-reconciliation handling. diff --git a/docs/adr/0006-zotero-research-intake.md b/docs/adr/0006-zotero-research-intake.md index b27b43ee..3600a494 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -17,7 +17,7 @@ ConceptWeave owns a small read-only Anti-Corruption Layer from Zotero into resea The adapter links child records, emits exactly one deterministic proposed disposition per top-level bibliographic item, and abstains when evidence is weak or ambiguous. Every abstention preserves a deterministic reason distinguishing missing classification metadata, vocabulary outside the current deterministic rules, present-but-unmatched metadata, and conflicting specific disposition families. Specific rule families are evaluated together rather than by first-match priority. When evidence matches multiple families, the proposal becomes `NeedsStewardReview` and all matching evidence is retained. -Matched metadata values are copied into the local-only evidence receipt for replay. This is necessary for abstract-only matches because a later Zotero revision cannot reconstruct the exact text used for an earlier proposal from item key/version alone. An abstention likewise retains its nonempty abstract so a steward can resolve unsupported or unmatched vocabulary from the same immutable report. If matched evidence already contains the abstract, the review-only field is omitted so sensitive text appears once; decided items also omit that extra copy. The report remains sensitive local material. DOI/title matches remain reversible duplicate candidates, including legacy `dx.doi.org` resolver forms. +Matched metadata values are copied into the local-only evidence receipt for replay. This is necessary for abstract-only matches because a later Zotero revision cannot reconstruct the exact text used for an earlier proposal from item key/version alone. An abstention likewise retains its nonempty abstract so a steward can resolve unsupported or unmatched vocabulary from the same immutable report. If matched evidence already contains the abstract, the review-only field is omitted so sensitive text appears once; decided items also omit that extra copy. On supported Unix platforms, the sensitive local report is created with exact owner-only `0600` permissions after applying the process umask; other platforms fail closed. DOI/title matches remain reversible duplicate candidates, including legacy `dx.doi.org` resolver forms. Duplicate candidates become canonical references only through externally verified steward decisions bound to the raw digest, complete item-key/item-version snapshot, and exact candidate membership. Overlapping candidates form one connected component and must select one component-level canonical item. Every resulting operation retains all component source revisions and complete before/after/rollback key mappings. It changes downstream identity resolution only; classification does not merge, delete, or mutate Zotero source records. @@ -36,6 +36,20 @@ No dedicated utility repository or Zotero mutation path is created. A future Zot ## Consequences +### Private output failure amendment (Proposed, 2026-09-06) + +The checked canonical parent is used to reconstruct the output path, reusing +existing fix `86288cdf5959040a95221c2ca2d99e243d25dc27` as `25154b6` rather than +introducing another path policy. The report is opened exclusively with mode +`0600`, then permissions are enforced on its handle before any report bytes are +serialized. If enforcement fails, `48d7068` returns the error without unlinking +the pathname: it may now refer to an unrelated replacement. An inode comparison +followed by unlink would still race, so that alternative is rejected. The downside +is a possible empty private file requiring later deliberate cleanup; confidentiality +and unrelated-file preservation take precedence over automatic cleanup. RED +`7cfc7fb` demonstrates both raw-parent reuse and replacement deletion. This policy +must also reach the later shared private-output writer before final adoption. + ### 2026-09-05 integrity amendment (Proposed) In the context of replaying a Zotero research classification against a steward's approved labels, facing source fields lost during projection and predictions mutable after review, we decided for separate source-and-input and proposal-content digests verified with the complete reviewed set, and against typed-only source hashing or a report's self-declared cached proposal identity, to preserve the exact evidence used for evaluation, accepting a receipt-format break, report regeneration and fresh governance approval. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index a2778345..5aa39db3 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -89,6 +89,45 @@ Remaining work: mandatory adoption by restoration, worksheet, duplicate and writ ## DDD fitness constraints +### PR #23 private report path repair (2026-09-06) + +Untouched `2a3619f52e1d3e4f699c91be1fc2d0e9a6e234c8` passed 121 tests/23 suites. +Normal merge `01d6e3f` preserves that delta and PR #22 `51d1682`; integration +passed 145/23. Unix creation-time `0600`, handle-based permission enforcement, +exclusive creation, and non-Unix rejection remain intact. + +Independent review found two existing defects. RED `7cfc7fb` compiled and failed +two of five CLI tests: raw `/tmp` was returned instead of the checked canonical +parent, and permission failure deleted an unrelated replacement at the output +path. Existing canonical owner fix `86288cdf5959040a95221c2ca2d99e243d25dc27` +was reused with provenance as `25154b6`. `48d7068` propagates permission errors +without unlinking a pathname that may have changed. Report serialization never +starts on that failure; an empty private file may remain for deliberate cleanup. +The regression verifies no group/other mode bits before the injected setter and +preserves the replacement sentinel. Only synthetic temporary files were used. + +`c0db7ff` corrects the old raw-system-temp test expectation. Coverage then exposed +the reachable nameless `..` case; `522e46b` reuses the later-owner rejection test +and removes incidental branches from test cleanup, with no coverage exclusions +or weakened runtime checks. Independent review found no further production issue. +The later shared private-output writer still needs this no-unlink failure policy. + +Final 147 tests/23 suites including three doctests, strict Clippy, warnings-denied +rustdoc, format/CI-contract/diff and unchanged coverage pass. Coverage: 278/278 +functions, 2404/2404 normalized regions, 404/404 normalized branches; raw LLVM +3183/3244 lines, 4795/4896 regions, 360/404 branches remain below 100%. +Logs use `/tmp/conceptweave-pr23-private-` with `red.log`, `final.log` (old path +expectation failure), `verified.log`, `clippy-verified.log`, `rustdoc-verified.log` +and `coverage-verified.log`; baseline/integration use `pr23-scope-` instead. +README and Proposed ADR record the empty-file downside and rejected racy cleanup. + +No fresh visual evidence was collected; latest native attempt encountered the +locked Mac. Historical 3,719 displayed items are not reclassification evidence. +Real decisions/approvals remain 0/3,715 plus four unresolved sources; no actual +authorization, mutation, recovery, protected merge or release occurred. Next +verified successor is PR #24 complete review evaluation +`1e73e1545de32ae9a349c469a7794c5c3fc2ae9b`; root and shared-writer adoption remain open. + ### PR #22 steward-context binding verification (2026-09-06) Untouched `7179d13b45d160682e4cce1473c145d465fe657b` passed 120 tests/23 suites.