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
48 changes: 34 additions & 14 deletions crates/core/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::SystemTime;

use regex::Regex;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -368,7 +367,8 @@ fn inspect_target(path: &std::path::Path, protected: bool) -> TargetInfo {
pub struct PolicyStore {
policy: Policy,
path: Option<PathBuf>,
mtime: Option<SystemTime>,
source: Option<Vec<u8>>,
source_valid: bool,
session_allow: HashSet<String>,
}

Expand All @@ -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.
Expand All @@ -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 {
Expand All @@ -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<Policy, PolicyError> {
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),
}
}

Expand Down
8 changes: 6 additions & 2 deletions crates/core/src/session/environment_review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
));
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions crates/core/tests/environment_review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
29 changes: 29 additions & 0 deletions crates/core/tests/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
10 changes: 10 additions & 0 deletions docs/release-0.9.0-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 23 additions & 3 deletions scripts/macos_sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import shutil
import subprocess
import sys
import time
import tempfile
import uuid

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


Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions tests/collaboration/scripts/header-input-regressions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
13 changes: 13 additions & 0 deletions tests/release/test_macos_sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading