diff --git a/crates/core/src/policy.rs b/crates/core/src/policy.rs index 2ef98db..574e4fe 100644 --- a/crates/core/src/policy.rs +++ b/crates/core/src/policy.rs @@ -5,7 +5,6 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; -use std::time::SystemTime; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -368,7 +367,8 @@ fn inspect_target(path: &std::path::Path, protected: bool) -> TargetInfo { pub struct PolicyStore { policy: Policy, path: Option, - mtime: Option, + source: Option>, + source_valid: bool, session_allow: HashSet, } @@ -381,7 +381,7 @@ pub enum Reload { impl PolicyStore { pub fn from_policy(policy: Policy) -> Self { - Self { policy, path: None, mtime: None, session_allow: HashSet::new() } + Self { policy, path: None, source: None, source_valid: true, session_allow: HashSet::new() } } /// Load from `path`, writing the example policy first if the file does not exist. @@ -392,9 +392,9 @@ impl PolicyStore { } std::fs::write(path, EXAMPLE_POLICY)?; } - let policy = Policy::load(path)?; - let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok(); - Ok(Self { policy, path: Some(path.to_path_buf()), mtime, session_allow: HashSet::new() }) + let source = std::fs::read(path)?; + let policy = Self::parse_source(&source)?; + Ok(Self { policy, path: Some(path.to_path_buf()), source: Some(source), source_valid: true, session_allow: HashSet::new() }) } pub fn policy(&self) -> &Policy { @@ -405,20 +405,40 @@ impl PolicyStore { self.path.as_deref() } - /// Re-read the file if its mtime changed. Keeps the old policy on failure. + pub fn source_valid(&self) -> bool { + self.source_valid + } + + fn parse_source(source: &[u8]) -> Result { + let text = std::str::from_utf8(source) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + Policy::parse(text) + } + + /// Compare contents: timestamps can coalesce across consecutive policy edits. + /// Keep the last valid policy on failure, but prohibit environment grants. pub fn maybe_reload(&mut self) -> Reload { let Some(path) = &self.path else { return Reload::Unchanged }; - let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok(); - if mtime == self.mtime { + let source = match std::fs::read(path) { + Ok(source) => source, + Err(error) => { + let changed = self.source.take().is_some() || self.source_valid; + self.source_valid = false; + return if changed { Reload::Failed(error.into()) } else { Reload::Unchanged }; + } + }; + if self.source.as_ref() == Some(&source) { return Reload::Unchanged; } - self.mtime = mtime; - match Policy::load(path) { - Ok(p) => { - self.policy = p; + let parsed = Self::parse_source(&source); + self.source = Some(source); + self.source_valid = parsed.is_ok(); + match parsed { + Ok(policy) => { + self.policy = policy; Reload::Reloaded } - Err(e) => Reload::Failed(e), + Err(error) => Reload::Failed(error), } } diff --git a/crates/core/src/session/environment_review.rs b/crates/core/src/session/environment_review.rs index cfed8e1..fbe0b5c 100644 --- a/crates/core/src/session/environment_review.rs +++ b/crates/core/src/session/environment_review.rs @@ -50,7 +50,10 @@ impl Session { if !self.process_alive { return Err(SessionError::ProcessExited); } - if self.conn_kind(conn) != Some(ConnKind::Agent) || !self.review_required() { + if self.conn_kind(conn) != Some(ConnKind::Agent) + || !self.review_required() + || !self.policy.source_valid() + { return Err(SessionError::InvalidInput( "environment_review_unavailable".into(), )); @@ -120,7 +123,8 @@ impl Session { now: Instant, ) -> Option<&EnvironmentReviewGrant> { self.environment_grants.get(&conn).filter(|g| { - g.conn == conn + self.policy.source_valid() + && g.conn == conn && now < g.expires && g.revision == self.environment_revision && g.profile_revision == self.profile_revision diff --git a/crates/core/tests/environment_review.rs b/crates/core/tests/environment_review.rs index 4c164c6..a5d6622 100644 --- a/crates/core/tests/environment_review.rs +++ b/crates/core/tests/environment_review.rs @@ -320,11 +320,24 @@ fn policy_reload_cancels_queued_command_before_any_enter() { let KeyResult::Scheduled { exec_id, .. } = submit(&mut h, "echo queued") else { panic!() }; + let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); std::fs::write(&path, "deny:\n - command: echo\n label: changed policy\ndefault: allow\nrequire_intent: false\n").unwrap(); + std::fs::File::options() + .write(true) + .open(&path) + .unwrap() + .set_modified(modified) + .unwrap(); h.session.tick(Instant::now() + Duration::from_secs(11)); assert!(h.session.execute_now_scheduled(&exec_id).is_err()); assert!(!h.pty_str().contains('\r')); assert!(h.session.status().environment_review.is_empty()); + std::fs::write(&path, "confirm: [\n").unwrap(); + assert!(h.session.start_environment_review(1).is_err()); + std::fs::remove_file(&path).unwrap(); + assert!(h.session.start_environment_review(1).is_err()); + std::fs::write(&path, Harness::test_policy()).unwrap(); + assert!(h.session.start_environment_review(1).is_ok()); } #[test] fn queued_profile_change_and_participant_removal_retain_input_without_execution() { diff --git a/crates/core/tests/policy.rs b/crates/core/tests/policy.rs index 4af7431..c94a267 100644 --- a/crates/core/tests/policy.rs +++ b/crates/core/tests/policy.rs @@ -65,3 +65,32 @@ fn session_allow_lifts_confirm_but_never_deny() { assert!(matches!(store.evaluate("rm -rf /"), Decision::Deny { .. })); assert_eq!(store.session_allows(), vec!["delete root".to_string(), "recursive delete".to_string()]); } + +#[test] +fn reload_detects_same_timestamp_and_length_and_tracks_invalid_source() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("policy.yaml"); + std::fs::write(&path, "default: allow\n").unwrap(); + let mut store = PolicyStore::open(&path).unwrap(); + let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); + for source in ["default: deny \n", "confirm: [\n", "default: allow\n"] { + std::fs::write(&path, source).unwrap(); + std::fs::File::options().write(true).open(&path).unwrap().set_modified(modified).unwrap(); + let result = store.maybe_reload(); + if source.starts_with("confirm") { + assert!(matches!(result, Reload::Failed(_))); + assert!(!store.source_valid()); + assert!(matches!(store.evaluate("echo x"), Decision::Deny { .. })); + } else { + assert!(matches!(result, Reload::Reloaded)); + assert!(store.source_valid()); + } + } + std::fs::remove_file(&path).unwrap(); + assert!(matches!(store.maybe_reload(), Reload::Failed(_))); + assert!(!store.source_valid()); + assert!(matches!(store.maybe_reload(), Reload::Unchanged)); + std::fs::write(&path, "default: allow\n").unwrap(); + assert!(matches!(store.maybe_reload(), Reload::Reloaded)); + assert!(store.source_valid()); +} diff --git a/docs/release-0.9.0-validation.md b/docs/release-0.9.0-validation.md index 720f1b3..ca8a30b 100644 --- a/docs/release-0.9.0-validation.md +++ b/docs/release-0.9.0-validation.md @@ -23,3 +23,13 @@ Implementation base: `744fa22f4396a3d4e762a29d6156ba4e12fcdb8b`, independently c Linux Chromium evidence does not establish native macOS/Windows interaction, Wayland, installed-app upgrades or all nested remote transitions. Unknown final host changes remain undetectable. No live user grant was enabled. Audit additions contain only decision identifiers, duration/revision and end reason. Release workflow, signing, checksums, exact-release package runtime and website publication are verified after the tagged pipeline; final status is recorded in the delivery report rather than inferred from local builds. + +## Tagged-run corrections + +The first tagged run `40354039491` failed and created no public release. Windows exposed that mtime-only policy reload could miss a fast policy edit before queued Enter. Policy reload now compares source bytes, including equal-length edits with unchanged timestamps, and refuses environment grants while the source is invalid or unreadable. The queue regression preserves the timestamp deliberately. Last-good policy fallback otherwise remains intact. + +The header regression failed only on extra trailing empty viewport rows during dock layout. The assertion now checks identical non-padding contents, cursor and column count, retaining both rejected-input checks. This does not waive input or cursor changes. + +macOS received notarization Accepted and completed stapling, but validation exited 68. An Apple service connection failure is a possible cause, not a confirmed diagnosis from the sanitized log. Ticket validation retries exit 68 at most twice; other failures and exhausted retries still block packaging. No signature, ticket, Gatekeeper or contained-DMG check is removed. Native success must still be established by the corrected release pipeline. + +Local correction checks: portable `cargo test` passed; 82 release-tool tests passed; all 15 header/input browser regressions passed. A full workspace native attempt remains blocked by missing local GTK development libraries. The original unpublished v0.9.0 tag is retained pending an explicit retag decision; no public artifacts were replaced. diff --git a/scripts/macos_sign.py b/scripts/macos_sign.py index 34d480a..6a51328 100644 --- a/scripts/macos_sign.py +++ b/scripts/macos_sign.py @@ -22,6 +22,7 @@ import shutil import subprocess import sys +import time import tempfile import uuid @@ -71,6 +72,12 @@ def import_failure_category(stderr: str) -> str: return "unclassified-import-error" +class NativeCommandError(release.ReleaseError): + def __init__(self, message, returncode): + super().__init__(message) + self.returncode = returncode + + def command(args, label, timeout=120, *, input_text=None): # Do not echo args or captured output: security/notarytool arguments can carry # secrets. All errors name only the fixed stage and numeric exit status. @@ -82,7 +89,7 @@ def command(args, label, timeout=120, *, input_text=None): raise release.ReleaseError(f"{label} could not complete") from None if result.returncode != 0: category = f"; category: {import_failure_category(result.stderr)}" if args[:2] == ["security", "import"] else "" - raise release.ReleaseError(f"{label} failed (exit {result.returncode}){category}; no release artifacts will be uploaded") + raise NativeCommandError(f"{label} failed (exit {result.returncode}){category}; no release artifacts will be uploaded", result.returncode) return result @@ -204,6 +211,19 @@ def verify_signature(path: Path, team: str, runtime=True, deep=False): return parse_signature(shown.stdout + shown.stderr, team, runtime) +def validate_ticket(path: Path, label: str): + # stapler exit 68 can report an Apple service connection failure even after + # successful stapling. Retry that status only; never skip ticket validation. + for attempt in range(3): + try: + command(["xcrun", "stapler", "validate", path], label) + return + except NativeCommandError as error: + if error.returncode != 68 or attempt == 2: + raise + time.sleep(5) + + def app_evidence(app: Path, team: str): if app.is_symlink() or not app.is_dir(): raise release.ReleaseError("Expected a regular Conn app bundle") @@ -217,7 +237,7 @@ def app_evidence(app: Path, team: str): raise release.ReleaseError("Expected distinct desktop executable and Conn sidecar") result = {"bundle": verify_signature(app, team, deep=True), "desktop": verify_signature(executable, team), "sidecar": verify_signature(sidecar, team)} - command(["xcrun", "stapler", "validate", app], "Validate app notarization ticket") + validate_ticket(app, "Validate app notarization ticket") command(["spctl", "--assess", "--type", "execute", app], "Assess app with Gatekeeper") result.update({"stapled": True, "gatekeeperAccepted": True}) return result @@ -307,7 +327,7 @@ def finish(target: str, path: Path, out: Path, root=release.ROOT, env=os.environ dmg_signature = verify_signature(dmg, team, runtime=False) dmg_notary = notarize(dmg, state) command(["xcrun", "stapler", "staple", dmg], "Staple DMG notarization ticket") - command(["xcrun", "stapler", "validate", dmg], "Validate DMG notarization ticket") + validate_ticket(dmg, "Validate DMG notarization ticket") command(["spctl", "--assess", "--type", "open", "--context", "context:primary-signature", dmg], "Assess DMG with Gatekeeper") verify_signature(dmg, team, runtime=False) inspect_dmg(dmg, team, app) diff --git a/tests/collaboration/scripts/header-input-regressions.mjs b/tests/collaboration/scripts/header-input-regressions.mjs index a162de9..0f57db7 100644 --- a/tests/collaboration/scripts/header-input-regressions.mjs +++ b/tests/collaboration/scripts/header-input-regressions.mjs @@ -108,10 +108,14 @@ try { await human('',['Control+c']);await human(text,keys);await control(agent);await run(agent,'REGRESSION_'+name.replaceAll('-','_'));await capture('input-'+name); } await human('',['Control+c']);await human("printf '%s\\n' HUMAN_SUFFIX",['Home','Control+u']);await control(agent); - const before=(await agent.tool('snapshot')).screen; + const before=await agent.tool('snapshot'); assert.match((await agent.tool('type',{text:"printf '%s\\n' AGENT_PREFIX; "})).error,/input_pending/); assert.match((await agent.tool('send_key',{key:'ENTER',intent:'Check retained input protection.'})).error,/input_pending/); - assert.deepEqual((await agent.tool('snapshot')).screen,before);await capture('retained-input-blocked');await human('',['Control+c']); + const after=await agent.tool('snapshot'); + // Dock layout may resize viewport padding while rejected input must leave content and cursor intact. + const content=screen=>{const rows=[...screen];while(rows.at(-1)==='')rows.pop();return rows;}; + assert.equal(after.size.cols,before.size.cols);assert.deepEqual(after.cursor,before.cursor); + assert.deepEqual(content(after.screen),content(before.screen));await capture('retained-input-blocked');await human('',['Control+c']); for(let count=2;count<=5;count++){await attach('same-agent');await layout(count);} await capture('repeated-five-summary'); for(let count=6;count<=7;count++)await attach('long-agent-name-repeated-for-overflow-abcdefghijklmnopqrst'); diff --git a/tests/release/test_macos_sign.py b/tests/release/test_macos_sign.py index 4d2eeac..bdf1867 100644 --- a/tests/release/test_macos_sign.py +++ b/tests/release/test_macos_sign.py @@ -129,6 +129,19 @@ def test_notarization_requires_accepted_status_and_submission_uuid(self): with self.assertRaises(release.ReleaseError): signing.accepted_submission(invalid) + def test_ticket_service_retry_never_skips_validation(self): + failure = signing.NativeCommandError("ticket service unavailable", 68) + with patch.object(signing, "command", side_effect=[failure, SimpleNamespace()]) as run, patch.object(signing.time, "sleep") as sleep: + signing.validate_ticket(Path("Conn.app"), "Validate app ticket") + self.assertEqual(run.call_count, 2) + self.assertTrue(all(call.args[0][2] == "validate" for call in run.call_args_list)) + sleep.assert_called_once_with(5) + for status, attempts in [(68, 3), (65, 1)]: + with patch.object(signing, "command", side_effect=signing.NativeCommandError("ticket invalid", status)) as run, patch.object(signing.time, "sleep"): + with self.assertRaisesRegex(release.ReleaseError, "ticket invalid"): + signing.validate_ticket(Path("Conn.app"), "Validate app ticket") + self.assertEqual(run.call_count, attempts) + def test_dmg_mounts_without_accepting_a_license_and_is_detached(self): dmg = Path(self.temporary.name) / "Conn.dmg" dmg.write_bytes(b"signed-dmg-fixture")