From e19350e25605641acd2e5769a05df398d835eacd Mon Sep 17 00:00:00 2001 From: mandalorianuy <45spells_origin@icloud.com> Date: Wed, 5 Aug 2026 11:42:35 -0300 Subject: [PATCH 1/2] feat: add shared hook state authority verifier --- .changeset/shared-hook-state-authority.md | 5 + crates/google-workspace-cli/src/main.rs | 7 + .../src/shared_hook_state.rs | 1009 +++++++++++++++++ .../tests/shared_hook_state_cli.rs | 461 ++++++++ docs/governance/shared-hook-state.md | 78 ++ docs/governance/shared-hook-state.v1.json | 28 + 6 files changed, 1588 insertions(+) create mode 100644 .changeset/shared-hook-state-authority.md create mode 100644 crates/google-workspace-cli/src/shared_hook_state.rs create mode 100644 crates/google-workspace-cli/tests/shared_hook_state_cli.rs create mode 100644 docs/governance/shared-hook-state.md create mode 100644 docs/governance/shared-hook-state.v1.json diff --git a/.changeset/shared-hook-state-authority.md b/.changeset/shared-hook-state-authority.md new file mode 100644 index 000000000..b7aa1570f --- /dev/null +++ b/.changeset/shared-hook-state-authority.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Add a read-only, fail-closed authority contract and verifier for shared Git hook state. diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 41dcc1e1f..8ad21f5f9 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -38,6 +38,7 @@ mod schema; mod services; mod setup; mod setup_tui; +mod shared_hook_state; mod text; mod timezone; mod token_storage; @@ -47,6 +48,11 @@ use error::{print_error_json, GwsError}; #[tokio::main] async fn main() { + let process_args: Vec = std::env::args().collect(); + if process_args.get(1).map(String::as_str) == Some(shared_hook_state::COMMAND_NAME) { + std::process::exit(shared_hook_state::run_cli(&process_args)); + } + // Load .env file if present (silently ignored if missing) let _ = dotenvy::dotenv(); @@ -443,6 +449,7 @@ fn print_usage() { println!("USAGE:"); println!(" gws [sub-resource] [flags]"); println!(" gws schema [--resolve-refs]"); + println!(" gws shared-hook-state"); println!(); println!("EXAMPLES:"); println!(" gws drive files list --params '{{\"pageSize\": 10}}'"); diff --git a/crates/google-workspace-cli/src/shared_hook_state.rs b/crates/google-workspace-cli/src/shared_hook_state.rs new file mode 100644 index 000000000..9fe011bdb --- /dev/null +++ b/crates/google-workspace-cli/src/shared_hook_state.rs @@ -0,0 +1,1009 @@ +//! Read-only, repository-owned verification of the exact shared Git hook state. +//! +//! The command deliberately has no mutation path. Its only filesystem writes +//! are performed by tests in task-local temporary repositories. + +use base64::Engine; +use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub const COMMAND_NAME: &str = "shared-hook-state"; + +pub const EXIT_PASS: i32 = 0; +pub const EXIT_UNDECIDED: i32 = 20; +pub const EXIT_CONTRACT_INVALID: i32 = 21; +pub const EXIT_GIT_UNRESOLVED: i32 = 22; +pub const EXIT_DRIFT: i32 = 23; +pub const EXIT_OBSERVATION_ERROR: i32 = 24; +pub const EXIT_INPUT_INVALID: i32 = 25; + +const CONTRACT_RELATIVE_PATH: &str = "docs/governance/shared-hook-state.v1.json"; +const CONTRACT_SCHEMA: &str = "shared_hook_state_authority_v1"; +const RESULT_SCHEMA: &str = "shared_hook_state_verification_result_v1"; +const MAX_ARTIFACT_BYTES: usize = 64 * 1024; +const MAX_OBSERVED_BYTES: u64 = 256 * 1024; +const MAX_DRIFT_ITEMS: usize = 12; + +#[derive(Clone, Copy)] +struct TargetSpec { + name: &'static str, + relative_path: &'static str, + common_git_relative_path: &'static str, +} + +const TARGET_SPECS: [TargetSpec; 3] = [ + TargetSpec { + name: "pre-commit", + relative_path: ".git/hooks/pre-commit", + common_git_relative_path: "hooks/pre-commit", + }, + TargetSpec { + name: "pre-push", + relative_path: ".git/hooks/pre-push", + common_git_relative_path: "hooks/pre-push", + }, + TargetSpec { + name: "lefthook.checksum", + relative_path: ".git/info/lefthook.checksum", + common_git_relative_path: "info/lefthook.checksum", + }, +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AuthorityStatus { + Undecided, + Proposed, + Decided, +} + +impl AuthorityStatus { + fn as_str(self) -> &'static str { + match self { + Self::Undecided => "UNDECIDED", + Self::Proposed => "PROPOSED", + Self::Decided => "DECIDED", + } + } +} + +#[derive(Clone)] +enum ExpectedState { + Absent, + Present { + sha256: String, + mode: String, + artifact: Vec, + }, +} + +struct Contract { + authority: AuthorityStatus, + decision_source: String, + expectations: [Option; 3], +} + +struct ContractError { + code: &'static str, +} + +impl ContractError { + const fn new(code: &'static str) -> Self { + Self { code } + } +} + +enum ContractLoad { + Valid(Box), + Missing, + Invalid(&'static str), +} + +struct ObservedState { + state: &'static str, + sha256: Option, + mode: Option, + size: Option, + reason: Option<&'static str>, + symlink: bool, +} + +impl ObservedState { + fn unavailable(reason: &'static str) -> Self { + Self { + state: "unavailable", + sha256: None, + mode: None, + size: None, + reason: Some(reason), + symlink: false, + } + } +} + +struct RepoPaths { + root: PathBuf, + common_git_dir: PathBuf, +} + +#[derive(Clone, Copy)] +enum ResolveError { + GitUnavailable, + NotRepository, + UnsafeCommonGitDir, +} + +impl ResolveError { + fn code(self) -> &'static str { + match self { + Self::GitUnavailable => "GIT_UNAVAILABLE", + Self::NotRepository => "NOT_A_REPOSITORY", + Self::UnsafeCommonGitDir => "UNSAFE_COMMON_GIT_DIR", + } + } +} + +pub struct VerificationOutcome { + pub result: Value, + pub exit_code: i32, +} + +/// Runs the command using only the process current directory as scope input. +/// +/// No repository root, Git directory, contract path, or target path can be +/// supplied through CLI arguments. +pub fn run_cli(args: &[String]) -> i32 { + if args.len() != 2 || args.get(1).map(String::as_str) != Some(COMMAND_NAME) { + let outcome = input_invalid_result(); + println!("{}", render_result(&outcome.result)); + return outcome.exit_code; + } + + let outcome = match std::env::current_dir() { + Ok(start) => verify_at(&start), + Err(_) => unresolved_result("CURRENT_DIRECTORY_UNAVAILABLE"), + }; + println!("{}", render_result(&outcome.result)); + outcome.exit_code +} + +fn render_result(result: &Value) -> String { + serde_json::to_string_pretty(result).unwrap_or_else(|_| { + "{\"schema\":\"shared_hook_state_verification_result_v1\",\"status\":\"BLOCKED\",\"failClosed\":true,\"exitCode\":24}".to_string() + }) +} + +fn verify_at(start: &Path) -> VerificationOutcome { + let paths = match resolve_repo_paths(start) { + Ok(paths) => paths, + Err(error) => return unresolved_result(error.code()), + }; + + let contract = load_contract(&paths.root); + let (contract_status, authority_status, decision_source, expectations, contract_error) = + match contract { + ContractLoad::Valid(contract) => { + let Contract { + authority, + decision_source, + expectations, + } = *contract; + ("VALID", authority, decision_source, expectations, None) + } + ContractLoad::Missing => ( + "MISSING", + AuthorityStatus::Undecided, + "none".to_string(), + [None, None, None], + Some("CONTRACT_MISSING"), + ), + ContractLoad::Invalid(code) => ( + "INVALID", + AuthorityStatus::Undecided, + "none".to_string(), + [None, None, None], + Some(code), + ), + }; + + let observed: [ObservedState; 3] = + std::array::from_fn(|index| observe_target(&paths.common_git_dir, TARGET_SPECS[index])); + + let target_values: Vec = TARGET_SPECS + .iter() + .enumerate() + .map(|(index, spec)| { + json!({ + "name": spec.name, + "relativePath": spec.relative_path, + "expected": expected_json(expectations[index].as_ref()), + "observed": observed_json(&observed[index]), + }) + }) + .collect(); + + let mut drift_items = Vec::new(); + if contract_status == "VALID" && authority_status == AuthorityStatus::Decided { + for (index, spec) in TARGET_SPECS.iter().enumerate() { + drift_items.extend(drift_for_target( + spec.name, + expectations[index].as_ref(), + &observed[index], + )); + } + } + let truncated = drift_items.len() > MAX_DRIFT_ITEMS; + drift_items.truncate(MAX_DRIFT_ITEMS); + + let has_observation_error = observed.iter().any(|state| { + matches!( + state.state, + "unavailable" | "unreadable" | "too_large" | "changed_during_read" + ) + }); + + let (status, drift_status, exit_code, fail_closed, error_code) = if contract_status != "VALID" { + ( + "BLOCKED", + "not_assessed", + EXIT_CONTRACT_INVALID, + true, + contract_error, + ) + } else if authority_status != AuthorityStatus::Decided { + ( + "BLOCKED", + "not_assessed", + EXIT_UNDECIDED, + true, + Some("AUTHORITY_NOT_DECIDED"), + ) + } else if has_observation_error { + ( + "BLOCKED", + "detected", + EXIT_OBSERVATION_ERROR, + true, + Some("OBSERVATION_ERROR"), + ) + } else if drift_items.is_empty() { + ("PASS", "none", EXIT_PASS, false, None) + } else { + ("DRIFT", "detected", EXIT_DRIFT, true, Some("TARGET_DRIFT")) + }; + + VerificationOutcome { + result: json!({ + "schema": RESULT_SCHEMA, + "schemaVersion": 1, + "status": status, + "authorityStatus": authority_status.as_str(), + "authority": { + "status": authority_status.as_str(), + "source": decision_source, + }, + "contractStatus": contract_status, + "contractPath": CONTRACT_RELATIVE_PATH, + "commonGitDirStatus": "RESOLVED", + "scope": { + "source": "git rev-parse --git-common-dir", + "mutation": "none", + }, + "targets": target_values, + "drift": { + "status": drift_status, + "items": drift_items, + "truncated": truncated, + }, + "failClosed": fail_closed, + "errorCode": error_code, + "exitCode": exit_code, + }), + exit_code, + } +} + +fn unresolved_result(error_code: &'static str) -> VerificationOutcome { + VerificationOutcome { + result: json!({ + "schema": RESULT_SCHEMA, + "schemaVersion": 1, + "status": "BLOCKED", + "authorityStatus": "UNDECIDED", + "authority": { + "status": "UNDECIDED", + "source": "none", + }, + "contractStatus": "NOT_READ", + "contractPath": CONTRACT_RELATIVE_PATH, + "commonGitDirStatus": "UNRESOLVED", + "scope": { + "source": "git rev-parse --git-common-dir", + "mutation": "none", + }, + "targets": unavailable_target_values("unavailable"), + "drift": { + "status": "not_assessed", + "items": [], + "truncated": false, + }, + "failClosed": true, + "errorCode": error_code, + "exitCode": EXIT_GIT_UNRESOLVED, + }), + exit_code: EXIT_GIT_UNRESOLVED, + } +} + +fn input_invalid_result() -> VerificationOutcome { + VerificationOutcome { + result: json!({ + "schema": RESULT_SCHEMA, + "schemaVersion": 1, + "status": "BLOCKED", + "authorityStatus": "UNDECIDED", + "authority": { + "status": "UNDECIDED", + "source": "none", + }, + "contractStatus": "NOT_READ", + "contractPath": CONTRACT_RELATIVE_PATH, + "commonGitDirStatus": "UNRESOLVED", + "scope": { + "source": "git rev-parse --git-common-dir", + "mutation": "none", + }, + "targets": unavailable_target_values("input_invalid"), + "drift": { + "status": "not_assessed", + "items": [], + "truncated": false, + }, + "failClosed": true, + "errorCode": "INPUT_INVALID", + "exitCode": EXIT_INPUT_INVALID, + }), + exit_code: EXIT_INPUT_INVALID, + } +} + +fn unavailable_target_values(reason: &'static str) -> Vec { + TARGET_SPECS + .iter() + .map(|spec| { + json!({ + "name": spec.name, + "relativePath": spec.relative_path, + "expected": Value::Null, + "observed": { + "state": "unavailable", + "reason": reason, + }, + }) + }) + .collect() +} + +fn resolve_repo_paths(start: &Path) -> Result { + let start = start + .canonicalize() + .map_err(|_| ResolveError::NotRepository)?; + let root_text = git_value(&start, &["rev-parse", "--show-toplevel"])?; + let root = PathBuf::from(root_text) + .canonicalize() + .map_err(|_| ResolveError::NotRepository)?; + if !root.is_dir() { + return Err(ResolveError::NotRepository); + } + + let common_text = git_value(&start, &["rev-parse", "--git-common-dir"])?; + let common_candidate = { + let raw = PathBuf::from(common_text); + if raw.is_absolute() { + raw + } else { + root.join(raw) + } + }; + if has_symlink_component(&common_candidate) { + return Err(ResolveError::UnsafeCommonGitDir); + } + let common_metadata = + fs::symlink_metadata(&common_candidate).map_err(|_| ResolveError::UnsafeCommonGitDir)?; + if common_metadata.file_type().is_symlink() || !common_metadata.is_dir() { + return Err(ResolveError::UnsafeCommonGitDir); + } + let common_git_dir = common_candidate + .canonicalize() + .map_err(|_| ResolveError::UnsafeCommonGitDir)?; + if common_git_dir.file_name().and_then(|name| name.to_str()) != Some(".git") { + return Err(ResolveError::UnsafeCommonGitDir); + } + + Ok(RepoPaths { + root, + common_git_dir, + }) +} + +fn git_value(start: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .current_dir(start) + .env("GIT_OPTIONAL_LOCKS", "0") + .args(args) + .output() + .map_err(|_| ResolveError::GitUnavailable)?; + if !output.status.success() { + return Err(ResolveError::NotRepository); + } + let raw = String::from_utf8(output.stdout).map_err(|_| ResolveError::NotRepository)?; + let value = raw.trim_end_matches(['\r', '\n']); + if value.is_empty() || value.chars().any(char::is_control) { + return Err(ResolveError::NotRepository); + } + Ok(value.to_string()) +} + +fn has_symlink_component(path: &Path) -> bool { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component.as_os_str()); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => return true, + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => return false, + Err(_) => return false, + } + } + false +} + +fn load_contract(root: &Path) -> ContractLoad { + let contract_path = root.join(CONTRACT_RELATIVE_PATH); + let metadata = match fs::symlink_metadata(&contract_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return ContractLoad::Missing, + Err(_) => return ContractLoad::Invalid("CONTRACT_UNREADABLE"), + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return ContractLoad::Invalid("CONTRACT_SYMLINK_OR_TYPE"); + } + let contents = match fs::read_to_string(&contract_path) { + Ok(contents) => contents, + Err(_) => return ContractLoad::Invalid("CONTRACT_UNREADABLE"), + }; + let value = match parse_strict_json(&contents) { + Ok(value) => value, + Err(error) => return ContractLoad::Invalid(error.code), + }; + if validate_value_strings(&value).is_err() { + return ContractLoad::Invalid("CONTROL_CHARACTER"); + } + match parse_contract(&value) { + Ok(contract) => ContractLoad::Valid(Box::new(contract)), + Err(error) => ContractLoad::Invalid(error.code), + } +} + +fn parse_contract(value: &Value) -> Result { + let root = object(value)?; + exact_keys(root, &["schema", "schemaVersion", "authority", "targets"])?; + if string_field(root, "schema")? != CONTRACT_SCHEMA { + return Err(ContractError::new("SCHEMA_MISMATCH")); + } + if number_field(root, "schemaVersion")? != 1 { + return Err(ContractError::new("SCHEMA_VERSION_MISMATCH")); + } + + let authority = object(root.get("authority").expect("exact authority key"))?; + exact_keys( + authority, + &[ + "status", + "decisionId", + "decisionSource", + "basis", + "mutationAllowed", + ], + )?; + let authority_status = match string_field(authority, "status")? { + "UNDECIDED" => AuthorityStatus::Undecided, + "PROPOSED" => AuthorityStatus::Proposed, + "DECIDED" => AuthorityStatus::Decided, + _ => return Err(ContractError::new("UNKNOWN_AUTHORITY_STATUS")), + }; + let decision_id = authority.get("decisionId").expect("exact decisionId key"); + match (authority_status, decision_id) { + (AuthorityStatus::Decided, Value::String(value)) + if !value.is_empty() && value.len() <= 128 => {} + (AuthorityStatus::Undecided | AuthorityStatus::Proposed, Value::Null) => {} + (AuthorityStatus::Decided, _) => return Err(ContractError::new("INVALID_DECISION_ID")), + _ => return Err(ContractError::new("NON_AUTHORITATIVE_DECISION_ID")), + } + let decision_source = string_field(authority, "decisionSource")?; + if !matches!(decision_source, "human" | "repository") { + return Err(ContractError::new("UNKNOWN_DECISION_SOURCE")); + } + let basis = string_field(authority, "basis")?; + if basis.is_empty() || basis.len() > 1024 { + return Err(ContractError::new("INVALID_BASIS")); + } + if authority.get("mutationAllowed").and_then(Value::as_bool) != Some(false) { + return Err(ContractError::new("MUTATION_NOT_ALLOWED")); + } + + let targets = root + .get("targets") + .and_then(Value::as_array) + .ok_or_else(|| ContractError::new("TARGETS_NOT_ARRAY"))?; + if targets.len() != TARGET_SPECS.len() { + return Err(ContractError::new("TARGET_COUNT_MISMATCH")); + } + + let mut seen = [false; 3]; + let mut expectations: [Option; 3] = [None, None, None]; + for target_value in targets { + let target = object(target_value)?; + exact_keys(target, &["name", "relativePath", "expected"])?; + let name = string_field(target, "name")?; + let index = TARGET_SPECS + .iter() + .position(|spec| spec.name == name) + .ok_or_else(|| ContractError::new("UNKNOWN_TARGET"))?; + if seen[index] { + return Err(ContractError::new("DUPLICATE_TARGET")); + } + seen[index] = true; + if string_field(target, "relativePath")? != TARGET_SPECS[index].relative_path { + return Err(ContractError::new("UNSAFE_TARGET_PATH")); + } + + let expected = target.get("expected").expect("exact expected key"); + if authority_status == AuthorityStatus::Decided { + expectations[index] = Some(parse_expected(expected)?); + } else if !expected.is_null() { + return Err(ContractError::new("NON_AUTHORITATIVE_EXPECTATION")); + } + } + if seen.iter().any(|seen| !seen) { + return Err(ContractError::new("TARGET_SET_INCOMPLETE")); + } + + Ok(Contract { + authority: authority_status, + decision_source: decision_source.to_string(), + expectations, + }) +} + +fn parse_expected(value: &Value) -> Result { + let expected = object(value)?; + let state = string_field(expected, "state")?; + match state { + "absent" => { + exact_keys(expected, &["state"])?; + Ok(ExpectedState::Absent) + } + "present" => { + exact_keys(expected, &["state", "sha256", "mode", "artifact"])?; + let sha256 = string_field(expected, "sha256")?.to_string(); + validate_sha256(&sha256)?; + let mode = string_field(expected, "mode")?.to_string(); + parse_mode(&mode)?; + let artifact = object(expected.get("artifact").expect("exact artifact key"))?; + exact_keys(artifact, &["encoding", "content"])?; + if string_field(artifact, "encoding")? != "base64" { + return Err(ContractError::new("UNKNOWN_ARTIFACT_ENCODING")); + } + let content = string_field(artifact, "content")?; + if content.len() > MAX_ARTIFACT_BYTES.div_ceil(3) * 4 { + return Err(ContractError::new("ARTIFACT_TOO_LARGE")); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(content.as_bytes()) + .map_err(|_| ContractError::new("INVALID_ARTIFACT_BASE64"))?; + if decoded.len() > MAX_ARTIFACT_BYTES + || base64::engine::general_purpose::STANDARD.encode(&decoded) != content + { + return Err(ContractError::new("INVALID_ARTIFACT_BASE64")); + } + if sha256_hex(&decoded) != sha256 { + return Err(ContractError::new("ARTIFACT_HASH_MISMATCH")); + } + Ok(ExpectedState::Present { + sha256, + mode, + artifact: decoded, + }) + } + _ => Err(ContractError::new("UNKNOWN_EXPECTED_STATE")), + } +} + +fn object(value: &Value) -> Result<&Map, ContractError> { + value + .as_object() + .ok_or_else(|| ContractError::new("EXPECTED_OBJECT")) +} + +fn exact_keys(object: &Map, expected: &[&str]) -> Result<(), ContractError> { + if object.len() != expected.len() + || object + .keys() + .any(|key| !expected.iter().any(|expected_key| *expected_key == key)) + { + return Err(ContractError::new("UNKNOWN_OR_MISSING_FIELD")); + } + Ok(()) +} + +fn string_field<'a>(object: &'a Map, field: &str) -> Result<&'a str, ContractError> { + object + .get(field) + .and_then(Value::as_str) + .ok_or_else(|| ContractError::new("EXPECTED_STRING")) +} + +fn number_field(object: &Map, field: &str) -> Result { + object + .get(field) + .and_then(Value::as_u64) + .ok_or_else(|| ContractError::new("EXPECTED_INTEGER")) +} + +fn validate_sha256(value: &str) -> Result<(), ContractError> { + if value.len() != 64 + || value + .bytes() + .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte)) + { + return Err(ContractError::new("INVALID_SHA256")); + } + Ok(()) +} + +fn parse_mode(value: &str) -> Result { + if value.len() != 4 + || !value.starts_with('0') + || !value.bytes().all(|byte| (b'0'..=b'7').contains(&byte)) + { + return Err(ContractError::new("INVALID_MODE")); + } + let parsed = u32::from_str_radix(value, 8).map_err(|_| ContractError::new("INVALID_MODE"))?; + if parsed > 0o777 { + return Err(ContractError::new("INVALID_MODE")); + } + Ok(parsed) +} + +fn expected_json(expected: Option<&ExpectedState>) -> Value { + match expected { + None => Value::Null, + Some(ExpectedState::Absent) => json!({ "state": "absent" }), + Some(ExpectedState::Present { + sha256, + mode, + artifact, + }) => json!({ + "state": "present", + "sha256": sha256, + "mode": mode, + "artifact": { + "encoding": "base64", + "size": artifact.len(), + "sha256": sha256, + }, + }), + } +} + +fn observed_json(observed: &ObservedState) -> Value { + let mut result = Map::new(); + result.insert("state".to_string(), json!(observed.state)); + if let Some(sha256) = &observed.sha256 { + result.insert("sha256".to_string(), json!(sha256)); + } + if let Some(mode) = &observed.mode { + result.insert("mode".to_string(), json!(mode)); + } + if let Some(size) = observed.size { + result.insert("size".to_string(), json!(size)); + } + if let Some(reason) = observed.reason { + result.insert("reason".to_string(), json!(reason)); + } + if observed.symlink { + result.insert("symlink".to_string(), json!(true)); + } + Value::Object(result) +} + +fn observe_target(common_git_dir: &Path, spec: TargetSpec) -> ObservedState { + let target_path = common_git_dir.join(spec.common_git_relative_path); + let metadata = match fs::symlink_metadata(&target_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return ObservedState { + state: "absent", + sha256: None, + mode: None, + size: None, + reason: None, + symlink: false, + }; + } + Err(_) => return ObservedState::unavailable("LSTAT_FAILED"), + }; + if metadata.file_type().is_symlink() { + return ObservedState { + state: "symlink", + sha256: None, + mode: None, + size: Some(metadata.len()), + reason: Some("SYMLINK_REJECTED"), + symlink: true, + }; + } + if !metadata.is_file() { + return ObservedState { + state: "other", + sha256: None, + mode: None, + size: Some(metadata.len()), + reason: Some("NOT_REGULAR_FILE"), + symlink: false, + }; + } + let mode = mode_string(&metadata); + let size = metadata.len(); + if size > MAX_OBSERVED_BYTES { + return ObservedState { + state: "too_large", + sha256: None, + mode: Some(mode), + size: Some(size), + reason: Some("OBSERVED_SIZE_LIMIT"), + symlink: false, + }; + } + let bytes = match fs::read(&target_path) { + Ok(bytes) => bytes, + Err(_) => return ObservedState::unavailable("READ_FAILED"), + }; + let after = match fs::symlink_metadata(&target_path) { + Ok(after) => after, + Err(_) => return ObservedState::unavailable("POST_READ_LSTAT_FAILED"), + }; + if after.file_type().is_symlink() + || !after.is_file() + || after.len() != size + || after.len() != bytes.len() as u64 + || mode_string(&after) != mode + { + return ObservedState { + state: "changed_during_read", + sha256: None, + mode: Some(mode), + size: Some(after.len()), + reason: Some("TARGET_CHANGED_DURING_READ"), + symlink: after.file_type().is_symlink(), + }; + } + ObservedState { + state: "present", + sha256: Some(sha256_hex(&bytes)), + mode: Some(mode), + size: Some(bytes.len() as u64), + reason: None, + symlink: false, + } +} + +#[cfg(unix)] +fn mode_string(metadata: &fs::Metadata) -> String { + use std::os::unix::fs::PermissionsExt; + format!("{:04o}", metadata.permissions().mode() & 0o7777) +} + +#[cfg(not(unix))] +fn mode_string(_metadata: &fs::Metadata) -> String { + "0000".to_string() +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn drift_for_target( + name: &str, + expected: Option<&ExpectedState>, + observed: &ObservedState, +) -> Vec { + let Some(expected) = expected else { + return Vec::new(); + }; + match expected { + ExpectedState::Absent => match observed.state { + "absent" => Vec::new(), + "symlink" => vec![json!({ "name": name, "kind": "symlink" })], + "present" => vec![json!({ "name": name, "kind": "unexpected_present" })], + _ => vec![json!({ "name": name, "kind": "wrong_type" })], + }, + ExpectedState::Present { sha256, mode, .. } => { + if observed.state != "present" { + let kind = match observed.state { + "absent" => "missing", + "symlink" => "symlink", + "unavailable" | "unreadable" | "too_large" | "changed_during_read" => { + "observation_unavailable" + } + _ => "wrong_type", + }; + return vec![json!({ "name": name, "kind": kind })]; + } + let mut drift = Vec::new(); + if observed.sha256.as_deref() != Some(sha256.as_str()) { + drift.push(json!({ "name": name, "kind": "hash_mismatch" })); + } + if observed.mode.as_deref() != Some(mode.as_str()) { + drift.push(json!({ "name": name, "kind": "mode_mismatch" })); + } + drift + } + } +} + +fn validate_value_strings(value: &Value) -> Result<(), ContractError> { + match value { + Value::String(value) if value.chars().any(is_unsafe_control) => { + Err(ContractError::new("CONTROL_CHARACTER")) + } + Value::Array(values) => values.iter().try_for_each(validate_value_strings), + Value::Object(values) => { + if values.keys().any(|key| key.chars().any(is_unsafe_control)) { + return Err(ContractError::new("CONTROL_CHARACTER")); + } + values.values().try_for_each(validate_value_strings) + } + _ => Ok(()), + } +} + +fn is_unsafe_control(character: char) -> bool { + character.is_control() + || matches!( + character, + '\u{200b}' + | '\u{200c}' + | '\u{200d}' + | '\u{2028}' + | '\u{2029}' + | '\u{202e}' + | '\u{2060}' + | '\u{2066}' + | '\u{2067}' + | '\u{2069}' + ) +} + +fn parse_strict_json(contents: &str) -> Result { + let mut deserializer = serde_json::Deserializer::from_str(contents); + let value = StrictValue::deserialize(&mut deserializer) + .map_err(|_| ContractError::new("INVALID_JSON"))? + .0; + deserializer + .end() + .map_err(|_| ContractError::new("TRAILING_JSON"))?; + Ok(value) +} + +struct StrictValue(Value); + +impl<'de> Deserialize<'de> for StrictValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct StrictVisitor; + + impl<'de> Visitor<'de> for StrictVisitor { + type Value = Value; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value with unique object keys") + } + + fn visit_bool(self, value: bool) -> Result + where + E: de::Error, + { + Ok(Value::Bool(value)) + } + + fn visit_i64(self, value: i64) -> Result + where + E: de::Error, + { + Ok(Value::Number(value.into())) + } + + fn visit_u64(self, value: u64) -> Result + where + E: de::Error, + { + Ok(Value::Number(value.into())) + } + + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + serde_json::Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("invalid JSON number")) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + Ok(Value::String(value.to_string())) + } + + fn visit_string(self, value: String) -> Result + where + E: de::Error, + { + Ok(Value::String(value)) + } + + fn visit_none(self) -> Result + where + E: de::Error, + { + Ok(Value::Null) + } + + fn visit_unit(self) -> Result + where + E: de::Error, + { + Ok(Value::Null) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element::()? { + values.push(value.0); + } + Ok(Value::Array(values)) + } + + fn visit_map(self, mut map_access: A) -> Result + where + A: MapAccess<'de>, + { + let mut values = Map::new(); + while let Some(key) = map_access.next_key::()? { + if values.contains_key(&key) { + return Err(de::Error::custom("duplicate JSON object key")); + } + let value = map_access.next_value::()?; + values.insert(key, value.0); + } + Ok(Value::Object(values)) + } + } + + deserializer.deserialize_any(StrictVisitor).map(StrictValue) + } +} diff --git a/crates/google-workspace-cli/tests/shared_hook_state_cli.rs b/crates/google-workspace-cli/tests/shared_hook_state_cli.rs new file mode 100644 index 000000000..d3310f3f3 --- /dev/null +++ b/crates/google-workspace-cli/tests/shared_hook_state_cli.rs @@ -0,0 +1,461 @@ +use base64::Engine; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; +use std::process::Command; + +const CONTRACT_RELATIVE_PATH: &str = "docs/governance/shared-hook-state.v1.json"; +const PRE_COMMIT_BYTES: &[u8] = b"precommit-authority\n"; +const PRE_COMMIT_SHA256: &str = "bb3b9766964cfe4b473b36650b5fdefc016c308b9c3eb5d4aef9bc6691f8e6f3"; +const CHECKSUM_BYTES: &[u8] = b"checksum-authority\n"; +const CHECKSUM_SHA256: &str = "1b242e7759d9174dc49e0b2df7858f2e375b29bbed5d21d2673d3871c633d181"; + +fn present_expected(sha256: &str, mode: &str, bytes: &[u8]) -> Value { + json!({ + "state": "present", + "sha256": sha256, + "mode": mode, + "artifact": { + "encoding": "base64", + "content": base64::engine::general_purpose::STANDARD.encode(bytes), + }, + }) +} + +fn absent_expected() -> Value { + json!({ "state": "absent" }) +} + +fn target(name: &str, relative_path: &str, expected: Value) -> Value { + json!({ + "name": name, + "relativePath": relative_path, + "expected": expected, + }) +} + +fn decided_contract(pre_commit: Value, pre_push: Value, checksum: Value) -> Value { + json!({ + "schema": "shared_hook_state_authority_v1", + "schemaVersion": 1, + "authority": { + "status": "DECIDED", + "decisionId": "fixture-decision-001", + "decisionSource": "repository", + "basis": "task-local fixture decision", + "mutationAllowed": false, + }, + "targets": [ + target("pre-commit", ".git/hooks/pre-commit", pre_commit), + target("pre-push", ".git/hooks/pre-push", pre_push), + target("lefthook.checksum", ".git/info/lefthook.checksum", checksum), + ], + }) +} + +fn proposed_contract() -> Value { + json!({ + "schema": "shared_hook_state_authority_v1", + "schemaVersion": 1, + "authority": { + "status": "PROPOSED", + "decisionId": null, + "decisionSource": "repository", + "basis": "no exact preimage decision is active", + "mutationAllowed": false, + }, + "targets": [ + target("pre-commit", ".git/hooks/pre-commit", Value::Null), + target("pre-push", ".git/hooks/pre-push", Value::Null), + target("lefthook.checksum", ".git/info/lefthook.checksum", Value::Null), + ], + }) +} + +fn fixture() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("fixture tempdir"); + let status = Command::new("git") + .args(["init", "--quiet"]) + .current_dir(dir.path()) + .status() + .expect("git init should execute"); + assert!(status.success()); + fs::create_dir_all(dir.path().join("docs/governance")).expect("fixture contract directory"); + dir +} + +fn write_contract(root: &Path, contract: &Value) { + fs::write( + root.join(CONTRACT_RELATIVE_PATH), + serde_json::to_vec_pretty(contract).expect("fixture contract JSON"), + ) + .expect("write fixture contract"); +} + +fn write_raw_contract(root: &Path, contents: &str) { + fs::write(root.join(CONTRACT_RELATIVE_PATH), contents).expect("write raw fixture contract"); +} + +fn run_fixture(root: &Path) -> (Value, i32) { + run_fixture_args(root, &["shared-hook-state"]) +} + +fn run_fixture_args(root: &Path, args: &[&str]) -> (Value, i32) { + let output = Command::new(env!("CARGO_BIN_EXE_gws")) + .args(args) + .current_dir(root) + .output() + .expect("the gws binary should execute"); + let result: Value = serde_json::from_slice(&output.stdout) + .expect("shared-hook-state should emit structured JSON on stdout"); + (result, output.status.code().expect("stable exit code")) +} + +fn write_target(root: &Path, relative_path: &str, bytes: &[u8], mode: u32) { + let path = root.join(relative_path); + fs::create_dir_all(path.parent().expect("target parent")).expect("target parent"); + fs::write(&path, bytes).expect("write fixture target"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(mode)) + .expect("set fixture target mode"); + } +} + +fn drift_kinds(result: &Value) -> Vec<&str> { + result["drift"]["items"] + .as_array() + .expect("drift items array") + .iter() + .filter_map(|item| item["kind"].as_str()) + .collect() +} + +#[test] +fn shared_hook_state_is_structured_and_fail_closed_when_proposal_is_active() { + let output = Command::new(env!("CARGO_BIN_EXE_gws")) + .arg("shared-hook-state") + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .expect("the gws binary should execute"); + + let result: Value = serde_json::from_slice(&output.stdout) + .expect("shared-hook-state should emit structured JSON on stdout"); + + assert_eq!(result["schema"], "shared_hook_state_verification_result_v1"); + assert_eq!(result["authorityStatus"], "PROPOSED"); + assert_eq!(result["failClosed"], true); + assert_eq!(output.status.code(), Some(20)); +} + +#[test] +fn fixture_proposal_blocks_without_inventing_expected_states() { + let dir = fixture(); + write_contract(dir.path(), &proposed_contract()); + + let (result, code) = run_fixture(dir.path()); + + assert_eq!(code, 20); + assert_eq!(result["authorityStatus"], "PROPOSED"); + assert_eq!(result["failClosed"], true); + assert!(result["targets"] + .as_array() + .expect("targets") + .iter() + .all(|target| target["expected"].is_null())); +} + +#[test] +fn fixture_decision_matches_exact_present_and_absent_targets() { + let dir = fixture(); + write_target(dir.path(), ".git/hooks/pre-commit", PRE_COMMIT_BYTES, 0o755); + write_target( + dir.path(), + ".git/info/lefthook.checksum", + CHECKSUM_BYTES, + 0o644, + ); + write_contract( + dir.path(), + &decided_contract( + present_expected(PRE_COMMIT_SHA256, "0755", PRE_COMMIT_BYTES), + absent_expected(), + present_expected(CHECKSUM_SHA256, "0644", CHECKSUM_BYTES), + ), + ); + + let (result, code) = run_fixture(dir.path()); + + assert_eq!(code, 0); + assert_eq!(result["status"], "PASS"); + assert_eq!(result["authorityStatus"], "DECIDED"); + assert_eq!(result["failClosed"], false); + assert_eq!(result["drift"]["status"], "none"); + assert_eq!(result["targets"][0]["observed"]["state"], "present"); + assert_eq!(result["targets"][1]["observed"]["state"], "absent"); + assert_eq!(result["targets"][2]["observed"]["state"], "present"); +} + +#[test] +fn fixture_hash_mismatch_is_bounded_drift() { + let dir = fixture(); + write_target( + dir.path(), + ".git/hooks/pre-commit", + b"changed-content\n", + 0o755, + ); + write_contract( + dir.path(), + &decided_contract( + present_expected(PRE_COMMIT_SHA256, "0755", PRE_COMMIT_BYTES), + absent_expected(), + absent_expected(), + ), + ); + + let (result, code) = run_fixture(dir.path()); + + assert_eq!(code, 23); + assert_eq!(result["status"], "DRIFT"); + assert!(drift_kinds(&result).contains(&"hash_mismatch")); +} + +#[test] +fn fixture_mode_mismatch_is_bounded_drift() { + let dir = fixture(); + write_target(dir.path(), ".git/hooks/pre-commit", PRE_COMMIT_BYTES, 0o644); + write_contract( + dir.path(), + &decided_contract( + present_expected(PRE_COMMIT_SHA256, "0755", PRE_COMMIT_BYTES), + absent_expected(), + absent_expected(), + ), + ); + + let (result, code) = run_fixture(dir.path()); + + assert_eq!(code, 23); + assert!(drift_kinds(&result).contains(&"mode_mismatch")); +} + +#[test] +fn fixture_missing_present_target_is_not_accepted_as_absent() { + let dir = fixture(); + write_contract( + dir.path(), + &decided_contract( + present_expected(PRE_COMMIT_SHA256, "0755", PRE_COMMIT_BYTES), + absent_expected(), + absent_expected(), + ), + ); + + let (result, code) = run_fixture(dir.path()); + + assert_eq!(code, 23); + assert_eq!(result["targets"][0]["observed"]["state"], "absent"); + assert!(drift_kinds(&result).contains(&"missing")); +} + +#[test] +fn fixture_symlink_target_is_rejected() { + let dir = fixture(); + write_target(dir.path(), ".git/hooks/real-hook", PRE_COMMIT_BYTES, 0o755); + #[cfg(unix)] + std::os::unix::fs::symlink( + dir.path().join(".git/hooks/real-hook"), + dir.path().join(".git/hooks/pre-commit"), + ) + .expect("create fixture symlink"); + write_contract( + dir.path(), + &decided_contract( + present_expected(PRE_COMMIT_SHA256, "0755", PRE_COMMIT_BYTES), + absent_expected(), + absent_expected(), + ), + ); + + #[cfg(unix)] + { + let (result, code) = run_fixture(dir.path()); + assert_eq!(code, 23); + assert_eq!(result["targets"][0]["observed"]["state"], "symlink"); + assert!(drift_kinds(&result).contains(&"symlink")); + } +} + +#[test] +fn malformed_contract_rejects_unknown_state_extra_target_and_duplicate_target() { + let dir = fixture(); + let mut unknown = decided_contract( + json!({ "state": "unknown" }), + absent_expected(), + absent_expected(), + ); + write_contract(dir.path(), &unknown); + let (result, code) = run_fixture(dir.path()); + assert_eq!(code, 21); + assert_eq!(result["contractStatus"], "INVALID"); + assert_eq!(result["failClosed"], true); + + unknown["targets"] + .as_array_mut() + .expect("targets") + .push(target("extra", ".git/hooks/extra", Value::Null)); + write_contract(dir.path(), &unknown); + let (_, code) = run_fixture(dir.path()); + assert_eq!(code, 21); + + let mut duplicate = decided_contract(absent_expected(), absent_expected(), absent_expected()); + duplicate["targets"][1]["name"] = Value::String("pre-commit".to_string()); + write_contract(dir.path(), &duplicate); + let (_, code) = run_fixture(dir.path()); + assert_eq!(code, 21); +} + +#[test] +fn malformed_contract_rejects_duplicate_json_keys() { + let dir = fixture(); + write_raw_contract( + dir.path(), + r#"{"schema":"shared_hook_state_authority_v1","schema":"shared_hook_state_authority_v1","schemaVersion":1,"authority":{"status":"PROPOSED","decisionId":null,"decisionSource":"repository","basis":"fixture","mutationAllowed":false},"targets":[]}"#, + ); + + let (result, code) = run_fixture(dir.path()); + + assert_eq!(code, 21); + assert_eq!(result["contractStatus"], "INVALID"); +} + +#[test] +fn malformed_contract_rejects_invalid_hash_and_mode() { + let dir = fixture(); + let invalid_hash = decided_contract( + present_expected("not-a-sha256", "0755", PRE_COMMIT_BYTES), + absent_expected(), + absent_expected(), + ); + write_contract(dir.path(), &invalid_hash); + let (_, code) = run_fixture(dir.path()); + assert_eq!(code, 21); + + let invalid_mode = decided_contract( + present_expected(PRE_COMMIT_SHA256, "9999", PRE_COMMIT_BYTES), + absent_expected(), + absent_expected(), + ); + write_contract(dir.path(), &invalid_mode); + let (_, code) = run_fixture(dir.path()); + assert_eq!(code, 21); + + let mut invalid_artifact = decided_contract( + present_expected(PRE_COMMIT_SHA256, "0755", PRE_COMMIT_BYTES), + absent_expected(), + absent_expected(), + ); + invalid_artifact["targets"][0]["expected"]["artifact"]["content"] = + Value::String(base64::engine::general_purpose::STANDARD.encode(b"wrong-artifact\n")); + write_contract(dir.path(), &invalid_artifact); + let (_, code) = run_fixture(dir.path()); + assert_eq!(code, 21); +} + +#[test] +fn malformed_contract_rejects_unsafe_path_and_control_character() { + let dir = fixture(); + let mut unsafe_path = proposed_contract(); + unsafe_path["targets"][0]["relativePath"] = Value::String("../.ssh".to_string()); + write_contract(dir.path(), &unsafe_path); + let (_, code) = run_fixture(dir.path()); + assert_eq!(code, 21); + + let mut control = proposed_contract(); + control["authority"]["basis"] = Value::String("bad\u{0000}basis".to_string()); + write_contract(dir.path(), &control); + let (_, code) = run_fixture(dir.path()); + assert_eq!(code, 21); +} + +#[test] +fn missing_contract_is_fail_closed_and_undecided() { + let dir = fixture(); + + let (result, code) = run_fixture(dir.path()); + + assert_eq!(code, 21); + assert_eq!(result["authorityStatus"], "UNDECIDED"); + assert_eq!(result["contractStatus"], "MISSING"); + assert_eq!(result["failClosed"], true); +} + +#[test] +fn command_rejects_scope_expanding_path_arguments() { + let dir = fixture(); + write_contract(dir.path(), &proposed_contract()); + + let (result, code) = + run_fixture_args(dir.path(), &["shared-hook-state", "--repo", "../outside"]); + + assert_eq!(code, 25); + assert_eq!(result["errorCode"], "INPUT_INVALID"); + assert_eq!(result["commonGitDirStatus"], "UNRESOLVED"); +} + +#[cfg(unix)] +#[test] +fn symlinked_common_git_dir_blocks_resolution() { + let dir = fixture(); + let real_git_dir = dir.path().join(".git-real"); + fs::rename(dir.path().join(".git"), &real_git_dir).expect("move fixture git dir"); + std::os::unix::fs::symlink(&real_git_dir, dir.path().join(".git")) + .expect("symlink fixture git dir"); + + let (result, code) = run_fixture(dir.path()); + + assert_eq!(code, 22); + assert_eq!(result["commonGitDirStatus"], "UNRESOLVED"); + assert_eq!(result["errorCode"], "UNSAFE_COMMON_GIT_DIR"); +} + +#[test] +fn verifier_does_not_write_targets_or_change_modes() { + let dir = fixture(); + write_target(dir.path(), ".git/hooks/pre-commit", PRE_COMMIT_BYTES, 0o755); + write_contract( + dir.path(), + &decided_contract( + present_expected(PRE_COMMIT_SHA256, "0755", PRE_COMMIT_BYTES), + absent_expected(), + absent_expected(), + ), + ); + let target_path = dir.path().join(".git/hooks/pre-commit"); + let before_bytes = fs::read(&target_path).expect("read target before verification"); + #[cfg(unix)] + let before_mode = std::os::unix::fs::PermissionsExt::mode( + &fs::symlink_metadata(&target_path) + .expect("stat target before verification") + .permissions(), + ); + + let (_, code) = run_fixture(dir.path()); + + assert_eq!(code, 0); + assert_eq!( + fs::read(&target_path).expect("read target after verification"), + before_bytes + ); + #[cfg(unix)] + assert_eq!( + std::os::unix::fs::PermissionsExt::mode( + &fs::symlink_metadata(&target_path) + .expect("stat target after verification") + .permissions(), + ), + before_mode + ); +} diff --git a/docs/governance/shared-hook-state.md b/docs/governance/shared-hook-state.md new file mode 100644 index 000000000..f4c30a33c --- /dev/null +++ b/docs/governance/shared-hook-state.md @@ -0,0 +1,78 @@ +# Shared hook state authority + +shared-hook-state.v1.json is the repository-owned, machine-readable contract +for exactly these common-Git-dir targets: + +- .git/hooks/pre-commit +- .git/hooks/pre-push +- .git/info/lefthook.checksum + +The current contract is intentionally PROPOSED. It does not choose +absent or present, and therefore is not an authority for restoration. + +## Contract rules + +The contract has schema shared_hook_state_authority_v1 and version 1. +targets must contain exactly the three names and canonical relative paths +above, without duplicates or extra entries. A target expectation is either: + +- {"state":"absent"}; or +- {"state":"present","sha256":"<64 lowercase hex>","mode":"0xyz","artifact":{"encoding":"base64","content":"..."}}. + +Present artifacts are bounded to 64 KiB and their decoded bytes must hash to +the declared SHA-256. Modes contain four octal digits, start with 0, and +do not contain special permission bits. The verifier rejects unknown fields, +duplicate JSON keys, partial expectations, symlinks, traversal/control +characters, arbitrary paths, invalid hashes/modes, and target-set drift. + +Only authority.status = "DECIDED" with a non-empty decisionId, complete +exact expectations, and mutationAllowed = false can authorize a verification +PASS. UNDECIDED and PROPOSED are valid non-authoritative contract states; +the verifier reports them and blocks with exit code 20. + +## Read-only verification + +From the repository or any directory inside it, run: + +~~~text +gws shared-hook-state +~~~ + +The command resolves the repository root and common Git directory using +read-only git rev-parse calls. It accepts no root, contract, Git-directory, +or target path arguments. It never runs package managers, lifecycle scripts, +Lefthook installation, or mutation/restoration code. The output is structured +JSON containing authority status, contract status, observed and expected +values for every target, bounded drift, and failClosed. + +Stable exit codes are: + +| Code | Meaning | +| ---: | --- | +| 0 | Exact decided state observed | +| 20 | Authority is UNDECIDED or PROPOSED | +| 21 | Contract missing or invalid | +| 22 | Common Git directory cannot be safely resolved | +| 23 | Decided target drift | +| 24 | Target observation failed closed | +| 25 | CLI arguments are outside the fixed interface | + +## Future restoration handoff + +R2 does not restore anything. A separately authorized future task must: + +1. Receive an exact decision for all three targets; if the decision is absent, + leave this contract UNDECIDED/PROPOSED and stop. +2. Update the repository-owned contract to DECIDED, freeze the manifest + bytes and every present artifact, and verify the artifact hashes before any + shared-Git-dir mutation. +3. Keep the mutation/restoration operation separate from this verifier. The + operation must use only the exact canonical targets, preserve a preimage + receipt, and perform a readback through gws shared-hook-state. +4. Treat any invalid, stale, partial, symlinked, or mismatched receipt as a + fail-closed stop. Do not substitute a guessed absent/present state or + run pnpm, npm, yarn, npx, or an install command to obtain evidence. + +Green local tests, a valid proposal, or a zero-drift observation do not by +themselves constitute human acceptance, restoration authorization, delivery, +or a live hook effect. diff --git a/docs/governance/shared-hook-state.v1.json b/docs/governance/shared-hook-state.v1.json new file mode 100644 index 000000000..e2c430a96 --- /dev/null +++ b/docs/governance/shared-hook-state.v1.json @@ -0,0 +1,28 @@ +{ + "schema": "shared_hook_state_authority_v1", + "schemaVersion": 1, + "authority": { + "status": "PROPOSED", + "decisionId": null, + "decisionSource": "repository", + "basis": "R2 provides a read-only verifier; no exact preimage decision is active.", + "mutationAllowed": false + }, + "targets": [ + { + "name": "pre-commit", + "relativePath": ".git/hooks/pre-commit", + "expected": null + }, + { + "name": "pre-push", + "relativePath": ".git/hooks/pre-push", + "expected": null + }, + { + "name": "lefthook.checksum", + "relativePath": ".git/info/lefthook.checksum", + "expected": null + } + ] +} From 01a1bdc3684198386e1625191637aa31b8c97743 Mon Sep 17 00:00:00 2001 From: mandalorianuy <45spells_origin@icloud.com> Date: Wed, 5 Aug 2026 12:48:12 -0300 Subject: [PATCH 2/2] chore: decide shared hook state --- .changeset/shared-hook-state-authority.md | 2 +- .../tests/shared_hook_state_cli.rs | 10 +++-- docs/governance/shared-hook-state.md | 27 ++++++++----- docs/governance/shared-hook-state.v1.json | 38 +++++++++++++++---- 4 files changed, 56 insertions(+), 21 deletions(-) diff --git a/.changeset/shared-hook-state-authority.md b/.changeset/shared-hook-state-authority.md index b7aa1570f..5d943127d 100644 --- a/.changeset/shared-hook-state-authority.md +++ b/.changeset/shared-hook-state-authority.md @@ -2,4 +2,4 @@ "@googleworkspace/cli": patch --- -Add a read-only, fail-closed authority contract and verifier for shared Git hook state. +Add a read-only, fail-closed authority contract, exact forward decision, and verifier for shared Git hook state. diff --git a/crates/google-workspace-cli/tests/shared_hook_state_cli.rs b/crates/google-workspace-cli/tests/shared_hook_state_cli.rs index d3310f3f3..e14544787 100644 --- a/crates/google-workspace-cli/tests/shared_hook_state_cli.rs +++ b/crates/google-workspace-cli/tests/shared_hook_state_cli.rs @@ -133,7 +133,7 @@ fn drift_kinds(result: &Value) -> Vec<&str> { } #[test] -fn shared_hook_state_is_structured_and_fail_closed_when_proposal_is_active() { +fn canonical_shared_hook_decision_matches_observed_targets() { let output = Command::new(env!("CARGO_BIN_EXE_gws")) .arg("shared-hook-state") .current_dir(env!("CARGO_MANIFEST_DIR")) @@ -144,9 +144,11 @@ fn shared_hook_state_is_structured_and_fail_closed_when_proposal_is_active() { .expect("shared-hook-state should emit structured JSON on stdout"); assert_eq!(result["schema"], "shared_hook_state_verification_result_v1"); - assert_eq!(result["authorityStatus"], "PROPOSED"); - assert_eq!(result["failClosed"], true); - assert_eq!(output.status.code(), Some(20)); + assert_eq!(result["status"], "PASS"); + assert_eq!(result["authorityStatus"], "DECIDED"); + assert_eq!(result["failClosed"], false); + assert_eq!(result["drift"]["status"], "none"); + assert_eq!(output.status.code(), Some(0)); } #[test] diff --git a/docs/governance/shared-hook-state.md b/docs/governance/shared-hook-state.md index f4c30a33c..664800093 100644 --- a/docs/governance/shared-hook-state.md +++ b/docs/governance/shared-hook-state.md @@ -7,8 +7,16 @@ for exactly these common-Git-dir targets: - .git/hooks/pre-push - .git/info/lefthook.checksum -The current contract is intentionally PROPOSED. It does not choose -absent or present, and therefore is not an authority for restoration. +The current contract is DECIDED as a forward local-workstation policy. All +three targets are expected present with the exact bytes, SHA-256 values, and +modes embedded in the contract. This preserves the observed Lefthook state; +it does not claim that those bytes are the historical preimage from before the +incidental installation. + +The pinned hook scripts include the local Lefthook installation path generated +on this workstation. Moving the repository or regenerating hooks is therefore +expected to produce bounded drift and requires a new explicit decision rather +than silent contract refresh. ## Contract rules @@ -57,12 +65,14 @@ Stable exit codes are: | 24 | Target observation failed closed | | 25 | CLI arguments are outside the fixed interface | -## Future restoration handoff +## Decision and future restoration handoff -R2 does not restore anything. A separately authorized future task must: +R3B made no shared-Git-dir mutation because the observed state already matched +the forward decision. A future task that changes the decision or observes drift +must: -1. Receive an exact decision for all three targets; if the decision is absent, - leave this contract UNDECIDED/PROPOSED and stop. +1. Receive a new exact decision for all three targets; if no decision exists, + leave this contract unchanged and stop. 2. Update the repository-owned contract to DECIDED, freeze the manifest bytes and every present artifact, and verify the artifact hashes before any shared-Git-dir mutation. @@ -73,6 +83,5 @@ R2 does not restore anything. A separately authorized future task must: fail-closed stop. Do not substitute a guessed absent/present state or run pnpm, npm, yarn, npx, or an install command to obtain evidence. -Green local tests, a valid proposal, or a zero-drift observation do not by -themselves constitute human acceptance, restoration authorization, delivery, -or a live hook effect. +Green local tests or zero drift do not by themselves prove historical +restoration, remote delivery, or a live hook execution. diff --git a/docs/governance/shared-hook-state.v1.json b/docs/governance/shared-hook-state.v1.json index e2c430a96..c7299eb43 100644 --- a/docs/governance/shared-hook-state.v1.json +++ b/docs/governance/shared-hook-state.v1.json @@ -2,27 +2,51 @@ "schema": "shared_hook_state_authority_v1", "schemaVersion": 1, "authority": { - "status": "PROPOSED", - "decisionId": null, - "decisionSource": "repository", - "basis": "R2 provides a read-only verifier; no exact preimage decision is active.", + "status": "DECIDED", + "decisionId": "T10-CORR4-R3B-2026-08-05-present-v1", + "decisionSource": "human", + "basis": "Forward local-workstation decision: preserve the three currently observed Lefthook artifacts exactly because package.json declares prepare=lefthook install and lefthook.yml defines pre-commit/pre-push. This does not claim historical preimage restoration.", "mutationAllowed": false }, "targets": [ { "name": "pre-commit", "relativePath": ".git/hooks/pre-commit", - "expected": null + "expected": { + "state": "present", + "sha256": "55919a9331052e969652af6017b92ddc0e64565efb8984bf3f7a3c9bb15d983c", + "mode": "0755", + "artifact": { + "encoding": "base64", + "content": "IyEvYmluL3NoCgppZiBbICIkTEVGVEhPT0tfVkVSQk9TRSIgPSAiMSIgLW8gIiRMRUZUSE9PS19WRVJCT1NFIiA9ICJ0cnVlIiBdOyB0aGVuCiAgc2V0IC14CmZpCgppZiBbICIkTEVGVEhPT0siID0gIjAiIF07IHRoZW4KICBleGl0IDAKZmkKCmNhbGxfbGVmdGhvb2soKQp7CiAgaWYgdGVzdCAtbiAiJExFRlRIT09LX0JJTiIKICB0aGVuCiAgICAiJExFRlRIT09LX0JJTiIgIiRAIgogIGVsaWYgbGVmdGhvb2sgLWggPi9kZXYvbnVsbCAyPiYxCiAgdGhlbgogICAgbGVmdGhvb2sgIiRAIgogIGVsaWYgL1VzZXJzL2ZhY3VuZG8vZGVzYXJyb2xsby9nb29nbGUtY2xpL25vZGVfbW9kdWxlcy8ucG5wbS9sZWZ0aG9vay1kYXJ3aW4tYXJtNjRAMi4xLjIvbm9kZV9tb2R1bGVzL2xlZnRob29rLWRhcndpbi1hcm02NC9iaW4vbGVmdGhvb2sgLWggPi9kZXYvbnVsbCAyPiYxCiAgdGhlbgogICAgL1VzZXJzL2ZhY3VuZG8vZGVzYXJyb2xsby9nb29nbGUtY2xpL25vZGVfbW9kdWxlcy8ucG5wbS9sZWZ0aG9vay1kYXJ3aW4tYXJtNjRAMi4xLjIvbm9kZV9tb2R1bGVzL2xlZnRob29rLWRhcndpbi1hcm02NC9iaW4vbGVmdGhvb2sgIiRAIgogIGVsc2UKICAgIGRpcj0iJChnaXQgcmV2LXBhcnNlIC0tc2hvdy10b3BsZXZlbCkiCiAgICBvc0FyY2g9JCh1bmFtZSB8IHRyICdbOnVwcGVyOl0nICdbOmxvd2VyOl0nKQogICAgY3B1QXJjaD0kKHVuYW1lIC1tIHwgc2VkICdzL2FhcmNoNjQvYXJtNjQvO3MveDg2XzY0L3g2NC8nKQogICAgaWYgdGVzdCAtZiAiJGRpci9ub2RlX21vZHVsZXMvbGVmdGhvb2stJHtvc0FyY2h9LSR7Y3B1QXJjaH0vYmluL2xlZnRob29rIgogICAgdGhlbgogICAgICAiJGRpci9ub2RlX21vZHVsZXMvbGVmdGhvb2stJHtvc0FyY2h9LSR7Y3B1QXJjaH0vYmluL2xlZnRob29rIiAiJEAiCiAgICBlbGlmIHRlc3QgLWYgIiRkaXIvbm9kZV9tb2R1bGVzL0BldmlsbWFydGlhbnMvbGVmdGhvb2svYmluL2xlZnRob29rLSR7b3NBcmNofS0ke2NwdUFyY2h9L2xlZnRob29rIgogICAgdGhlbgogICAgICAiJGRpci9ub2RlX21vZHVsZXMvQGV2aWxtYXJ0aWFucy9sZWZ0aG9vay9iaW4vbGVmdGhvb2stJHtvc0FyY2h9LSR7Y3B1QXJjaH0vbGVmdGhvb2siICIkQCIKICAgIGVsaWYgdGVzdCAtZiAiJGRpci9ub2RlX21vZHVsZXMvQGV2aWxtYXJ0aWFucy9sZWZ0aG9vay1pbnN0YWxsZXIvYmluL2xlZnRob29rIgogICAgdGhlbgogICAgICAiJGRpci9ub2RlX21vZHVsZXMvQGV2aWxtYXJ0aWFucy9sZWZ0aG9vay1pbnN0YWxsZXIvYmluL2xlZnRob29rIiAiJEAiCiAgICBlbGlmIHRlc3QgLWYgIiRkaXIvbm9kZV9tb2R1bGVzL2xlZnRob29rL2Jpbi9pbmRleC5qcyIKICAgIHRoZW4KICAgICAgIiRkaXIvbm9kZV9tb2R1bGVzL2xlZnRob29rL2Jpbi9pbmRleC5qcyIgIiRAIgogICAgZWxpZiBnbyB0b29sIGxlZnRob29rIC1oID4vZGV2L251bGwgMj4mMQogICAgdGhlbgogICAgICBnbyB0b29sIGxlZnRob29rICIkQCIKICAgIGVsaWYgYnVuZGxlIGV4ZWMgbGVmdGhvb2sgLWggPi9kZXYvbnVsbCAyPiYxCiAgICB0aGVuCiAgICAgIGJ1bmRsZSBleGVjIGxlZnRob29rICIkQCIKICAgIGVsaWYgeWFybiBsZWZ0aG9vayAtaCA+L2Rldi9udWxsIDI+JjEKICAgIHRoZW4KICAgICAgeWFybiBsZWZ0aG9vayAiJEAiCiAgICBlbGlmIHBucG0gbGVmdGhvb2sgLWggPi9kZXYvbnVsbCAyPiYxCiAgICB0aGVuCiAgICAgIHBucG0gbGVmdGhvb2sgIiRAIgogICAgZWxpZiBzd2lmdCBwYWNrYWdlIGxlZnRob29rID4vZGV2L251bGwgMj4mMQogICAgdGhlbgogICAgICBzd2lmdCBwYWNrYWdlIC0tYnVpbGQtcGF0aCAuYnVpbGQvbGVmdGhvb2sgLS1kaXNhYmxlLXNhbmRib3ggbGVmdGhvb2sgIiRAIgogICAgZWxpZiBjb21tYW5kIC12IG1pbnQgPi9kZXYvbnVsbCAyPiYxCiAgICB0aGVuCiAgICAgIG1pbnQgcnVuIGNzam9uZXMvbGVmdGhvb2stcGx1Z2luICIkQCIKICAgIGVsaWYgdXYgcnVuIGxlZnRob29rIC1oID4vZGV2L251bGwgMj4mMQogICAgdGhlbgogICAgICB1diBydW4gbGVmdGhvb2sgIiRAIgogICAgZWxpZiBtaXNlIGV4ZWMgLS0gbGVmdGhvb2sgLWggPi9kZXYvbnVsbCAyPiYxCiAgICB0aGVuCiAgICAgIG1pc2UgZXhlYyAtLSBsZWZ0aG9vayAiJEAiCiAgICBlbGlmIGRldmJveCBydW4gbGVmdGhvb2sgLWggPi9kZXYvbnVsbCAyPiYxCiAgICB0aGVuCiAgICAgIGRldmJveCBydW4gbGVmdGhvb2sgIiRAIgogICAgZWxzZQogICAgICBlY2hvICJDYW4ndCBmaW5kIGxlZnRob29rIGluIFBBVEgiCiAgICBmaQogIGZpCn0KCmNhbGxfbGVmdGhvb2sgcnVuICJwcmUtY29tbWl0IiAiJEAiCg==" + } + } }, { "name": "pre-push", "relativePath": ".git/hooks/pre-push", - "expected": null + "expected": { + "state": "present", + "sha256": "62b911fd1272fa2c9a0d7fab1f310ca586bbe0921c2eede645eb779e54a1daf7", + "mode": "0755", + "artifact": { + "encoding": "base64", + "content": "IyEvYmluL3NoCgppZiBbICIkTEVGVEhPT0tfVkVSQk9TRSIgPSAiMSIgLW8gIiRMRUZUSE9PS19WRVJCT1NFIiA9ICJ0cnVlIiBdOyB0aGVuCiAgc2V0IC14CmZpCgppZiBbICIkTEVGVEhPT0siID0gIjAiIF07IHRoZW4KICBleGl0IDAKZmkKCmNhbGxfbGVmdGhvb2soKQp7CiAgaWYgdGVzdCAtbiAiJExFRlRIT09LX0JJTiIKICB0aGVuCiAgICAiJExFRlRIT09LX0JJTiIgIiRAIgogIGVsaWYgbGVmdGhvb2sgLWggPi9kZXYvbnVsbCAyPiYxCiAgdGhlbgogICAgbGVmdGhvb2sgIiRAIgogIGVsaWYgL1VzZXJzL2ZhY3VuZG8vZGVzYXJyb2xsby9nb29nbGUtY2xpL25vZGVfbW9kdWxlcy8ucG5wbS9sZWZ0aG9vay1kYXJ3aW4tYXJtNjRAMi4xLjIvbm9kZV9tb2R1bGVzL2xlZnRob29rLWRhcndpbi1hcm02NC9iaW4vbGVmdGhvb2sgLWggPi9kZXYvbnVsbCAyPiYxCiAgdGhlbgogICAgL1VzZXJzL2ZhY3VuZG8vZGVzYXJyb2xsby9nb29nbGUtY2xpL25vZGVfbW9kdWxlcy8ucG5wbS9sZWZ0aG9vay1kYXJ3aW4tYXJtNjRAMi4xLjIvbm9kZV9tb2R1bGVzL2xlZnRob29rLWRhcndpbi1hcm02NC9iaW4vbGVmdGhvb2sgIiRAIgogIGVsc2UKICAgIGRpcj0iJChnaXQgcmV2LXBhcnNlIC0tc2hvdy10b3BsZXZlbCkiCiAgICBvc0FyY2g9JCh1bmFtZSB8IHRyICdbOnVwcGVyOl0nICdbOmxvd2VyOl0nKQogICAgY3B1QXJjaD0kKHVuYW1lIC1tIHwgc2VkICdzL2FhcmNoNjQvYXJtNjQvO3MveDg2XzY0L3g2NC8nKQogICAgaWYgdGVzdCAtZiAiJGRpci9ub2RlX21vZHVsZXMvbGVmdGhvb2stJHtvc0FyY2h9LSR7Y3B1QXJjaH0vYmluL2xlZnRob29rIgogICAgdGhlbgogICAgICAiJGRpci9ub2RlX21vZHVsZXMvbGVmdGhvb2stJHtvc0FyY2h9LSR7Y3B1QXJjaH0vYmluL2xlZnRob29rIiAiJEAiCiAgICBlbGlmIHRlc3QgLWYgIiRkaXIvbm9kZV9tb2R1bGVzL0BldmlsbWFydGlhbnMvbGVmdGhvb2svYmluL2xlZnRob29rLSR7b3NBcmNofS0ke2NwdUFyY2h9L2xlZnRob29rIgogICAgdGhlbgogICAgICAiJGRpci9ub2RlX21vZHVsZXMvQGV2aWxtYXJ0aWFucy9sZWZ0aG9vay9iaW4vbGVmdGhvb2stJHtvc0FyY2h9LSR7Y3B1QXJjaH0vbGVmdGhvb2siICIkQCIKICAgIGVsaWYgdGVzdCAtZiAiJGRpci9ub2RlX21vZHVsZXMvQGV2aWxtYXJ0aWFucy9sZWZ0aG9vay1pbnN0YWxsZXIvYmluL2xlZnRob29rIgogICAgdGhlbgogICAgICAiJGRpci9ub2RlX21vZHVsZXMvQGV2aWxtYXJ0aWFucy9sZWZ0aG9vay1pbnN0YWxsZXIvYmluL2xlZnRob29rIiAiJEAiCiAgICBlbGlmIHRlc3QgLWYgIiRkaXIvbm9kZV9tb2R1bGVzL2xlZnRob29rL2Jpbi9pbmRleC5qcyIKICAgIHRoZW4KICAgICAgIiRkaXIvbm9kZV9tb2R1bGVzL2xlZnRob29rL2Jpbi9pbmRleC5qcyIgIiRAIgogICAgZWxpZiBnbyB0b29sIGxlZnRob29rIC1oID4vZGV2L251bGwgMj4mMQogICAgdGhlbgogICAgICBnbyB0b29sIGxlZnRob29rICIkQCIKICAgIGVsaWYgYnVuZGxlIGV4ZWMgbGVmdGhvb2sgLWggPi9kZXYvbnVsbCAyPiYxCiAgICB0aGVuCiAgICAgIGJ1bmRsZSBleGVjIGxlZnRob29rICIkQCIKICAgIGVsaWYgeWFybiBsZWZ0aG9vayAtaCA+L2Rldi9udWxsIDI+JjEKICAgIHRoZW4KICAgICAgeWFybiBsZWZ0aG9vayAiJEAiCiAgICBlbGlmIHBucG0gbGVmdGhvb2sgLWggPi9kZXYvbnVsbCAyPiYxCiAgICB0aGVuCiAgICAgIHBucG0gbGVmdGhvb2sgIiRAIgogICAgZWxpZiBzd2lmdCBwYWNrYWdlIGxlZnRob29rID4vZGV2L251bGwgMj4mMQogICAgdGhlbgogICAgICBzd2lmdCBwYWNrYWdlIC0tYnVpbGQtcGF0aCAuYnVpbGQvbGVmdGhvb2sgLS1kaXNhYmxlLXNhbmRib3ggbGVmdGhvb2sgIiRAIgogICAgZWxpZiBjb21tYW5kIC12IG1pbnQgPi9kZXYvbnVsbCAyPiYxCiAgICB0aGVuCiAgICAgIG1pbnQgcnVuIGNzam9uZXMvbGVmdGhvb2stcGx1Z2luICIkQCIKICAgIGVsaWYgdXYgcnVuIGxlZnRob29rIC1oID4vZGV2L251bGwgMj4mMQogICAgdGhlbgogICAgICB1diBydW4gbGVmdGhvb2sgIiRAIgogICAgZWxpZiBtaXNlIGV4ZWMgLS0gbGVmdGhvb2sgLWggPi9kZXYvbnVsbCAyPiYxCiAgICB0aGVuCiAgICAgIG1pc2UgZXhlYyAtLSBsZWZ0aG9vayAiJEAiCiAgICBlbGlmIGRldmJveCBydW4gbGVmdGhvb2sgLWggPi9kZXYvbnVsbCAyPiYxCiAgICB0aGVuCiAgICAgIGRldmJveCBydW4gbGVmdGhvb2sgIiRAIgogICAgZWxzZQogICAgICBlY2hvICJDYW4ndCBmaW5kIGxlZnRob29rIGluIFBBVEgiCiAgICBmaQogIGZpCn0KCmNhbGxfbGVmdGhvb2sgcnVuICJwcmUtcHVzaCIgIiRAIgo=" + } + } }, { "name": "lefthook.checksum", "relativePath": ".git/info/lefthook.checksum", - "expected": null + "expected": { + "state": "present", + "sha256": "e8448fb5c4738248828aeee4386b582b459c56abd7d5431546c4b3d2e63255a1", + "mode": "0644", + "artifact": { + "encoding": "base64", + "content": "Y2FkY2VmZDMwZDIzNDg1OTJkZTc3ZTA5ZGNmODI4ZTkgMTc4NTg5MDc3NyAK" + } + } } ] }