Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b3fd61c
test(zotero): require private report permissions
seonghobae Sep 4, 2026
c7f954d
fix(zotero): create local reports privately
seonghobae Sep 4, 2026
2563b55
docs(zotero): record private report boundary
seonghobae Sep 4, 2026
4416098
fix(zotero): enforce exact private report mode
seonghobae Sep 4, 2026
6672498
docs(zotero): scope private report support
seonghobae Sep 4, 2026
fbd83a1
test(zotero): cover private report failures
seonghobae Sep 4, 2026
5f81ad9
refactor(zotero): share report permission check path
seonghobae Sep 4, 2026
6c22e20
test(zotero): scope temp path assertions by platform
seonghobae Sep 4, 2026
48e7eee
Merge current steward-context parent into private report boundary
seonghobae Sep 4, 2026
d85eb57
Merge repaired write receipt evidence into zotero-private-report-perm…
seonghobae Sep 4, 2026
b93b641
chore(zotero): restack private report boundary
seonghobae Sep 4, 2026
ac26bca
Merge remote-tracking branch 'origin/autoresearch/zotero-steward-revi…
seonghobae Sep 4, 2026
d6fc92c
merge(zotero): adopt current steward-context parent
seonghobae Sep 4, 2026
17a0407
merge(zotero): adopt current steward-context parent and gap baseline
seonghobae Sep 5, 2026
67acff9
merge(research): inherit verified source and proposal approval binding
seonghobae Sep 5, 2026
df06965
merge(research): inherit canonical local transport repairs into PR #23
seonghobae Sep 5, 2026
fe1eb0c
merge(research): inherit deterministic transport framing regression i…
seonghobae Sep 5, 2026
1912c21
merge(zotero): propagate validated approval ordering through PR 23
seonghobae Sep 5, 2026
2a3619f
merge(research): inherit bounded metadata reads into PR #23
seonghobae Sep 6, 2026
01d6e3f
merge(research): retain private reports with repaired review scope
seonghobae Sep 6, 2026
7cfc7fb
test(zotero): expose private output replacement deletion
seonghobae Sep 6, 2026
25154b6
fix(zotero): rebuild output path from canonical parent
seonghobae Sep 4, 2026
48d7068
fix(zotero): avoid unlinking replaced private report paths
seonghobae Sep 6, 2026
c0db7ff
test(zotero): expect canonical system temp output path
seonghobae Sep 6, 2026
522e46b
test(zotero): cover nameless output and simplify fixture cleanup
seonghobae Sep 6, 2026
2d32f96
docs(research): record private output failure policy and evidence
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
180 changes: 163 additions & 17 deletions crates/conceptweave-zotero/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
let mut parents = vec![
env::temp_dir()
.canonicalize()
.expect("system temporary directory must exist"),
Path::new("/tmp").canonicalize().expect("/tmp must exist"),
]
];
#[cfg(unix)]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<PathBuf> {
let path = PathBuf::from(raw);
if !path.is_absolute() {
Expand All @@ -37,16 +41,61 @@ fn validate_output_path(raw: &str) -> io::Result<PathBuf> {
"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<File> {
#[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<File> {
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<dyn std::error::Error>> {
let output = env::args()
.nth(1)
Expand All @@ -56,10 +105,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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()?;
Expand All @@ -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(
Expand All @@ -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()));
Expand Down Expand Up @@ -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
);
}
}
2 changes: 1 addition & 1 deletion docs/TRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
16 changes: 15 additions & 1 deletion docs/adr/0006-zotero-research-intake.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down
Loading