diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4afaed4..360ca1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: DG-0 contracts +name: Contracts and service boundary on: push: @@ -24,14 +24,18 @@ jobs: persist-credentials: false - name: Select the qualification toolchain run: rustup toolchain install 1.95.0 --profile minimal --component clippy --component rustfmt - - name: DG-0 qualification (fake backend only) + - name: Contract regression and workspace validation run: python3 scripts/validate.py --output target/qualification/ci + - name: Canonical authority functional checks + run: python3 scripts/qualify.py dg1-authority --output target/qualification/dg1-authority + - name: Native local authentication functional checks + run: python3 scripts/qualify.py dg1-auth --output target/qualification/dg1-auth - name: Preserve qualification evidence if: always() uses: actions/upload-artifact@v6 with: name: dg0-${{ matrix.os }}-${{ github.run_attempt }} - path: target/qualification/ci/ + path: target/qualification/ if-no-files-found: error - name: Summarize qualification if: always() @@ -39,10 +43,10 @@ jobs: python3 - <<'PY' import json, os from pathlib import Path - path = Path('target/qualification/ci/report.json') - if path.exists(): - report = json.loads(path.read_text()) - with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as out: - out.write('DG-0: ' + report['status'] + '\n\n') - out.write('Fake-backend contracts only. Runtime/OS/SLO qualification is not run.\n') + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as out: + for name in ['ci', 'dg1-authority', 'dg1-auth']: + path = Path('target/qualification') / name / 'report.json' + status = json.loads(path.read_text())['status'] if path.exists() else 'not_run' + out.write(name + ': ' + status + '\n\n') + out.write('Contract regression and service/UDS functional checks only. Workload launch, OS resource control, self-use and foreground SLO qualification are not run.\n') PY diff --git a/Cargo.lock b/Cargo.lock index 897d7c4..597f099 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,6 +70,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "devguard-client" +version = "0.1.0" +dependencies = [ + "devguard-contract", + "libc", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "devguard-contract" version = "0.1.0" @@ -92,6 +103,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "devguard-daemon" +version = "0.1.0" +dependencies = [ + "devguard-client", + "devguard-contract", + "devguard-core", + "libc", + "serde", + "serde_json", + "tempfile", + "toml", + "uuid", +] + [[package]] name = "digest" version = "0.10.7" @@ -102,6 +128,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -190,13 +222,29 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", ] [[package]] @@ -363,6 +411,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "sha2" version = "0.10.9" @@ -427,6 +484,45 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "typenum" version = "1.20.1" @@ -522,6 +618,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "zerocopy" version = "0.8.57" diff --git a/Cargo.toml b/Cargo.toml index 9516845..f917054 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/contract", "crates/core"] +members = ["crates/contract", "crates/core", "crates/daemon", "crates/client"] [workspace.package] version = "0.1.0" @@ -18,6 +18,7 @@ rusqlite = { version = "0.32", features = ["bundled"] } tempfile = "3.27" uuid = { version = "1.20", features = ["v4"] } libc = "0.2" +toml = "=0.9.12" [profile.dev] debug = 1 diff --git a/README.md b/README.md index d51bccb..ec0d8b2 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,29 @@ DevGuard centralizes resource admission for development workloads while preserving the resources needed to inspect and stop them. -The repository currently implements **DG-0: contracts and a durable authority core**. It does not yet install a daemon, launch user commands, apply macOS policies, or enforce Linux cgroups. The CLI examples in the approved design describe DG-1 and later work. +The repository implements **DG-0 contracts and a durable authority core**, with DG-1 now in progress. C01 supplies canonical paths/bootstrap/storage checks; C02 supplies authenticated, bounded local communication with a small client and private credential-FD transfer. PR and post-merge main delivery evidence is tracked separately from implementation. Native registration, principals, leases and execution remain closed until the required native evidence and launch work in P2/P3; macOS resource policies and Linux cgroups are not yet available. Use the operating guide for actual command availability; the design also contains future interfaces. - [Authoritative design reference](docs/design.md) · [Korean translation](docs/ko/design.md) - [Historical approved design (Korean, immutable)](docs/design.ko.md) - [Implemented contracts and trust boundaries](docs/contracts.md) +- [Service boundary operations](docs/operations.md) - [Milestones and the CodeSpace dependency path](docs/milestones.md) - [Detailed execution plans, adoption gates and PR delivery](docs/planning/README.md) - [Machine-readable milestone state](milestones.json) -## Validate DG-0 +## Validate the current implementation Use Rust **1.95.0**, including rustfmt and Clippy, and Python 3.11 or newer. A rustup installation honors `rust-toolchain.toml`. A standalone toolchain can be placed first in `PATH`. The validator checks the compiler it actually executes. ```sh python3 scripts/validate.py --offline +python3 scripts/qualify.py dg1-authority --offline +python3 scripts/qualify.py dg1-auth --offline ``` Omit `--offline` when the locked crates have not been downloaded. Builds and tests use one Cargo job and one test thread by default. Results, source fingerprints and logs are written under `target/qualification/`. A newer compiler can be used with `--allow-toolchain-mismatch` for a supplemental check, which never counts as qualification for 1.95.0. -DG-0 tests use an explicitly fake OS backend. A passing report establishes the tested accounting, persistence and state-transition contracts. macOS launch, Linux enforcement, browser responsiveness and self-governed execution remain `not_run`. +DG-0 tests use an explicitly fake OS backend. Their passing reports establish accounting, persistence and state-transition contracts. The additional C01/C02 suites check actual local storage, peer observations and credential transport within bounded fixtures. They do not qualify native registration or launch, Linux enforcement, browser responsiveness or self-governed execution; these remain `not_run`. ## Repository boundaries @@ -29,7 +32,9 @@ DG-0 tests use an explicitly fake OS backend. A passing report establishes the t The approved local checkout is `/Volumes/DevData/Projects/IdeaProjects/DevGuard`. Existing `.codex` settings are preserved locally and ignored by Git. Build output, journals, qualification evidence and local toolchains are also ignored. The approved design is preserved byte-for-byte; its checksum is recorded in `docs/design-source.json`. -The public source repository is [novelKR/DevGuard](https://github.com/novelKR/DevGuard). CodeSpace runtime consumption begins at CS-RG after DG-1 qualification. The foundation does not publish crates or install a running host service. +`devguard-daemon` provides the canonical configuration/storage boundary and foreground `devguardd serve`. `devguard-client` supplies versioned UDS communication and private credential handoff without depending on the authority core. Successful authentication is not an instance registration or a workload lease, and does not isolate malicious processes sharing the operating UID. + +The public source repository is [novelKR/DevGuard](https://github.com/novelKR/DevGuard). CodeSpace runtime consumption begins at CS-RG after DG-1 qualification. Crates are not published, and no installer or LaunchAgent is available yet. The detailed plan defines 46 proposed implementation commit units in 23 logical PR groups. It records single registration by the execution-owning Runner and opt-in Gateway restart recovery while an independent Runner remains alive. Planning completion does not change runtime milestone status. See the [consumer readiness gates](docs/planning/consumer-readiness.md), [CodeSpace mapping](docs/planning/codespace-integration.md), and [verification and evidence rules](docs/planning/verification.md). diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml new file mode 100644 index 0000000..4dd698f --- /dev/null +++ b/crates/client/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "devguard-client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true +license.workspace = true +repository.workspace = true +description = "Small authenticated local DevGuard protocol client" + +[dependencies] +devguard-contract = { path = "../contract" } +serde.workspace = true +serde_json.workspace = true +libc.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/client/examples/inspect.rs b/crates/client/examples/inspect.rs new file mode 100644 index 0000000..a349ba1 --- /dev/null +++ b/crates/client/examples/inspect.rs @@ -0,0 +1,46 @@ +//! Bounded foreground inspection example; no workload registration or execution. +use devguard_client::{credential::take_inherited, protocol::CallerCredential, Client}; +use devguard_contract::{Compatibility, Error, ErrorCode, Result, PROTOCOL_VERSION}; +use std::collections::BTreeSet; +use std::path::Path; + +fn invalid() -> Error { + Error::new( + ErrorCode::InvalidRequest, + "usage: inspect SOCKET UID CONSUMER GENERATION CREDENTIAL_FD", + ) +} +fn run() -> Result<()> { + let args: Vec<_> = std::env::args().skip(1).collect(); + if args.len() != 5 { + return Err(invalid()); + } + let uid = args[1].parse().map_err(|_| invalid())?; + let fd = args[4].parse().map_err(|_| invalid())?; + // SAFETY: this dedicated executable takes ownership of the descriptor passed + // by its caller, before creating any clients or descriptors of its own. + let secret = unsafe { take_inherited(fd) }?; + let mut client = Client::connect( + Path::new(&args[0]), + uid, + Compatibility { + minimum_protocol: PROTOCOL_VERSION, + maximum_protocol: PROTOCOL_VERSION, + required: BTreeSet::new(), + }, + )?; + client.authenticate(CallerCredential::Consumer { + consumer_id: args[2].clone(), + generation: args[3].clone(), + secret, + })?; + let status = client.status()?; + println!("{}", serde_json::to_string(&serde_json::json!({"peer":client.hello.authority,"caller":client.hello.caller,"status":status})).map_err(|_| invalid())?); + Ok(()) +} +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} diff --git a/crates/client/src/connect.rs b/crates/client/src/connect.rs new file mode 100644 index 0000000..ed6bbcf --- /dev/null +++ b/crates/client/src/connect.rs @@ -0,0 +1,115 @@ +use std::io; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::time::{Duration, Instant}; + +/// Bounded local connect, including a full listener backlog. The returned socket +/// is blocking with CLOEXEC; protocol framing separately supplies absolute deadlines. +pub fn connect_timeout(path: &Path, timeout: Duration) -> io::Result { + let invalid = || { + io::Error::new( + io::ErrorKind::InvalidInput, + "invalid local endpoint or timeout", + ) + }; + let bytes = path.as_os_str().as_bytes(); + // SAFETY: sockaddr_un is a C output/address struct with a valid all-zero state. + let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + if !path.is_absolute() + || bytes.contains(&0) + || bytes.len() >= address.sun_path.len() + || timeout.is_zero() + || timeout > Duration::from_secs(5) + { + return Err(invalid()); + } + address.sun_family = libc::AF_UNIX as libc::sa_family_t; + for (target, source) in address.sun_path.iter_mut().zip(bytes) { + *target = *source as libc::c_char; + } + let length = + (std::mem::offset_of!(libc::sockaddr_un, sun_path) + bytes.len() + 1) as libc::socklen_t; + #[cfg(target_os = "macos")] + { + address.sun_len = length as u8; + } + // SAFETY: socket creates a fresh descriptor without borrowing memory. + let raw = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) }; + if raw < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: socket returned an owned descriptor. + let initial = unsafe { OwnedFd::from_raw_fd(raw) }; + // Do not occupy a missing stdin/stdout/stderr slot with an authenticated socket. + let duplicate = unsafe { libc::fcntl(initial.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 3) }; + if duplicate < 0 { + return Err(io::Error::last_os_error()); + } + let owned = unsafe { OwnedFd::from_raw_fd(duplicate) }; + drop(initial); + let raw = owned.as_raw_fd(); + if unsafe { libc::fcntl(raw, libc::F_SETFL, libc::O_NONBLOCK) } < 0 { + return Err(io::Error::last_os_error()); + } + let end = Instant::now().checked_add(timeout).ok_or_else(invalid)?; + // SAFETY: address and its native length remain valid for this connect call. + let result = + unsafe { libc::connect(raw, (&address as *const libc::sockaddr_un).cast(), length) }; + if result != 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::EINPROGRESS) { + return Err(error); + } + loop { + let remaining = end + .checked_duration_since(Instant::now()) + .filter(|r| !r.is_zero()) + .ok_or_else(|| io::Error::from(io::ErrorKind::TimedOut))?; + let mut poll = libc::pollfd { + fd: raw, + events: libc::POLLOUT, + revents: 0, + }; + // SAFETY: one live pollfd and a bounded millisecond timeout. + let ready = unsafe { libc::poll(&mut poll, 1, remaining.as_millis().max(1) as i32) }; + if ready < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + if ready == 0 { + return Err(io::ErrorKind::TimedOut.into()); + } + if ready < 0 { + return Err(io::Error::last_os_error()); + } + let mut error: libc::c_int = 0; + let mut size = std::mem::size_of_val(&error) as libc::socklen_t; + if unsafe { + libc::getsockopt( + raw, + libc::SOL_SOCKET, + libc::SO_ERROR, + (&mut error as *mut libc::c_int).cast(), + &mut size, + ) + } != 0 + { + return Err(io::Error::last_os_error()); + } + if size as usize != std::mem::size_of_val(&error) { + return Err(invalid()); + } + if error != 0 { + return Err(io::Error::from_raw_os_error(error)); + } + break; + } + } + if unsafe { libc::fcntl(raw, libc::F_SETFL, 0) } < 0 { + return Err(io::Error::last_os_error()); + } + let stream = UnixStream::from(owned); + stream.peer_addr()?; + Ok(stream) +} diff --git a/crates/client/src/credential.rs b/crates/client/src/credential.rs new file mode 100644 index 0000000..094b0df --- /dev/null +++ b/crates/client/src/credential.rs @@ -0,0 +1,125 @@ +use devguard_contract::{Error, ErrorCode, Result, Secret}; +use std::fs::File; +use std::io::{Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::os::unix::net::UnixStream; +use std::os::unix::process::CommandExt; +use std::process::Command; +use std::time::{Duration, Instant}; + +fn failed() -> Error { + Error::new( + ErrorCode::Unauthorized, + "invalid, missing or expired private credential FD", + ) +} + +/// One read-only carrier. Its writer is already closed; argv contains only the FD number. +pub struct CredentialHandoff(OwnedFd); +impl CredentialHandoff { + pub fn new(secret: &Secret) -> Result { + let (reader, mut writer) = UnixStream::pair().map_err(|_| failed())?; + writer + .write_all(secret.expose().as_bytes()) + .map_err(|_| failed())?; + drop(writer); + // Keep credentials out of stdio even if the caller has closed a standard FD. + // SAFETY: reader is live; fcntl duplicates it to a new close-on-exec descriptor. + let fd = unsafe { libc::fcntl(reader.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 3) }; + if fd < 0 { + return Err(failed()); + } + // SAFETY: successful F_DUPFD_CLOEXEC returned a newly owned descriptor. + Ok(Self(unsafe { OwnedFd::from_raw_fd(fd) })) + } + + /// Command owns the carrier until dropped. Only its child clears close-on-exec. + pub fn attach(self, command: &mut Command) -> RawFd { + let raw = self.0.as_raw_fd(); + let owned = self.0; + // SAFETY: the post-fork callback uses only async-signal-safe fcntl and errno; + // owned keeps the descriptor valid until spawn and is not manipulated there. + unsafe { + command.pre_exec(move || { + let fd = owned.as_raw_fd(); + let flags = libc::fcntl(fd, libc::F_GETFD); + if flags < 0 || libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + raw + } +} + +/// Consume and close the inherited credential descriptor on success and every error. +/// +/// # Safety +/// `fd` must be a dedicated inherited descriptor owned by this caller, with no other +/// Rust owner. It must not be a descriptor borrowed from another object/thread. +pub unsafe fn take_inherited(fd: RawFd) -> Result { + if fd < 3 || unsafe { libc::fcntl(fd, libc::F_GETFD) } < 0 { + return Err(failed()); + } + // SAFETY: ownership is the caller's explicit precondition above. + read_owned(unsafe { OwnedFd::from_raw_fd(fd) }) +} + +pub fn read_owned(fd: OwnedFd) -> Result { + if fd.as_raw_fd() < 3 { + return Err(failed()); + } + let raw = fd.as_raw_fd(); + // SAFETY: all operations use the descriptor held by fd. CLOEXEC is restored + // before reading any secret, and the descriptor is dropped on all paths. + let flags = unsafe { libc::fcntl(raw, libc::F_GETFD) }; + let status = unsafe { libc::fcntl(raw, libc::F_GETFL) }; + if flags < 0 + || status < 0 + || unsafe { libc::fcntl(raw, libc::F_SETFD, flags | libc::FD_CLOEXEC) } < 0 + || unsafe { libc::fcntl(raw, libc::F_SETFL, status | libc::O_NONBLOCK) } < 0 + { + return Err(failed()); + } + let mut file = File::from(fd); + let mut bytes = [0; 65]; + let mut size = 0; + let deadline = Instant::now() + Duration::from_millis(250); + while size < bytes.len() { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(failed)?; + if remaining.is_zero() { + return Err(failed()); + } + let mut poll = libc::pollfd { + fd: raw, + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: poll points to one live pollfd and timeout is at most 250 ms. + let ready = unsafe { libc::poll(&mut poll, 1, remaining.as_millis().max(1) as i32) }; + if ready < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted { + continue; + } + if ready <= 0 { + return Err(failed()); + } + match file.read(&mut bytes[size..]) { + Ok(0) => break, + Ok(n) => size += n, + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock + ) => {} + Err(_) => return Err(failed()), + } + } + if size != 64 { + return Err(failed()); + } + Secret::new(String::from_utf8(bytes[..size].to_vec()).map_err(|_| failed())?) + .map_err(|_| failed()) +} diff --git a/crates/client/src/framing.rs b/crates/client/src/framing.rs new file mode 100644 index 0000000..4bd97ab --- /dev/null +++ b/crates/client/src/framing.rs @@ -0,0 +1,275 @@ +use crate::protocol::MAX_FRAME_BYTES; +use devguard_contract::{Error, ErrorCode, Result}; +use serde::{de::DeserializeOwned, Serialize}; +use std::io; +use std::os::fd::AsRawFd; +use std::os::unix::net::UnixStream; +use std::time::{Duration, Instant}; + +fn unavailable() -> Error { + Error::new( + ErrorCode::ResourceControlUnavailable, + "local protocol closed, unavailable or deadline exceeded", + ) +} +fn invalid() -> Error { + Error::new( + ErrorCode::InvalidRequest, + "invalid or oversized protocol frame", + ) +} +fn deadline(timeout: Duration) -> Result { + if timeout.is_zero() || timeout > Duration::from_secs(5) { + return Err(invalid()); + } + Instant::now().checked_add(timeout).ok_or_else(invalid) +} +fn remaining(end: Instant) -> Result { + end.checked_duration_since(Instant::now()) + .filter(|t| !t.is_zero()) + .ok_or_else(unavailable) +} +fn configure_nonblocking(stream: &UnixStream) -> Result<()> { + // SAFETY: stream retains ownership. Fcntl changes file status rather than + // socket timeout options, so buffered data remains readable after peer close. + let flags = unsafe { libc::fcntl(stream.as_raw_fd(), libc::F_GETFL) }; + if flags < 0 + || (flags & libc::O_NONBLOCK == 0 + && unsafe { libc::fcntl(stream.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) } + < 0) + { + return Err(unavailable()); + } + Ok(()) +} +fn ready(stream: &UnixStream, events: libc::c_short, end: Instant) -> Result<()> { + loop { + let timeout = remaining(end)?; + let mut descriptor = libc::pollfd { + fd: stream.as_raw_fd(), + events, + revents: 0, + }; + // SAFETY: one live descriptor and a deadline bounded to at most five seconds. + let result = unsafe { libc::poll(&mut descriptor, 1, timeout.as_millis().max(1) as i32) }; + if result < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + if result < 0 || descriptor.revents & libc::POLLNVAL != 0 { + return Err(unavailable()); + } + if result > 0 { + remaining(end)?; + // HUP can accompany buffered final data. Let recv drain it before EOF. + return Ok(()); + } + } +} +fn read_exact(stream: &mut UnixStream, mut bytes: &mut [u8], end: Instant) -> Result<()> { + while !bytes.is_empty() { + ready(stream, libc::POLLIN, end)?; + // SAFETY: valid socket and writable slice. Per-call nonblocking avoids + // socket-option changes after Darwin peer close (which can return EINVAL). + let count = unsafe { + libc::recv( + stream.as_raw_fd(), + bytes.as_mut_ptr().cast(), + bytes.len(), + libc::MSG_DONTWAIT, + ) + }; + if count > 0 { + bytes = &mut bytes[count as usize..]; + } else if count == 0 + || !matches!( + io::Error::last_os_error().kind(), + io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock + ) + { + return Err(unavailable()); + } + } + Ok(()) +} +fn write_all(stream: &mut UnixStream, mut bytes: &[u8], end: Instant) -> Result<()> { + while !bytes.is_empty() { + ready(stream, libc::POLLOUT, end)?; + // SAFETY: valid socket and readable slice; suppress SIGPIPE for callers + // that do not inherit Rust's default signal disposition. + let count = unsafe { + libc::send( + stream.as_raw_fd(), + bytes.as_ptr().cast(), + bytes.len(), + libc::MSG_DONTWAIT | libc::MSG_NOSIGNAL, + ) + }; + if count > 0 { + bytes = &bytes[count as usize..]; + } else if count == 0 + || !matches!( + io::Error::last_os_error().kind(), + io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock + ) + { + return Err(unavailable()); + } + } + Ok(()) +} + +/// Reads one bounded frame. The exclusively used stream is left nonblocking. +pub fn read_frame(stream: &mut UnixStream, timeout: Duration) -> Result { + let end = deadline(timeout)?; + configure_nonblocking(stream)?; + let mut header = [0; 4]; + read_exact(stream, &mut header, end)?; + let size = u32::from_be_bytes(header) as usize; + if size == 0 || size > MAX_FRAME_BYTES { + return Err(invalid()); + } + let mut body = vec![0; size]; + read_exact(stream, &mut body, end)?; + // Do not return serde diagnostics: unknown keys and invalid strings may be secrets. + serde_json::from_slice(&body).map_err(|_| invalid()) +} + +/// Writes one bounded frame. The exclusively used stream is left nonblocking. +pub fn write_frame( + stream: &mut UnixStream, + value: &T, + timeout: Duration, +) -> Result<()> { + let end = deadline(timeout)?; + configure_nonblocking(stream)?; + let bytes = serde_json::to_vec(value).map_err(|_| invalid())?; + if bytes.is_empty() || bytes.len() > MAX_FRAME_BYTES { + return Err(invalid()); + } + write_all(stream, &(bytes.len() as u32).to_be_bytes(), end)?; + write_all(stream, &bytes, end) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{Frame, Request}; + use std::io::Write; + + #[test] + fn final_frame_survives_peer_close_before_the_first_read() { + let (mut left, mut right) = UnixStream::pair().unwrap(); + write_frame( + &mut left, + &serde_json::json!({"final":true}), + Duration::from_millis(250), + ) + .unwrap(); + drop(left); + let result: serde_json::Value = read_frame(&mut right, Duration::from_millis(250)).unwrap(); + assert_eq!(result["final"], true); + } + + #[test] + fn slow_reader_cannot_extend_write_deadline() { + let (mut left, right) = UnixStream::pair().unwrap(); + let (finished, completion) = std::sync::mpsc::channel::<()>(); + // A regression must fail, not hang the entire qualification indefinitely. + let watchdog = std::thread::spawn(move || { + let _ = completion.recv_timeout(Duration::from_secs(1)); + drop(right); + }); + let small: libc::c_int = 4096; + // SAFETY: live socket and correctly sized integer socket-option input. + assert_eq!( + unsafe { + libc::setsockopt( + left.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_SNDBUF, + (&small as *const libc::c_int).cast(), + std::mem::size_of_val(&small) as libc::socklen_t, + ) + }, + 0 + ); + let began = Instant::now(); + let result = write_frame( + &mut left, + &"x".repeat(MAX_FRAME_BYTES - 2), + Duration::from_millis(100), + ); + let elapsed = began.elapsed(); + let _ = finished.send(()); + watchdog.join().unwrap(); + assert_eq!( + result.unwrap_err().code, + ErrorCode::ResourceControlUnavailable + ); + assert!(elapsed < Duration::from_millis(800)); + } + + #[test] + fn framing_roundtrip_and_truncated_reply_preserve_uncertainty() { + let (mut left, mut right) = UnixStream::pair().unwrap(); + write_frame( + &mut left, + &serde_json::json!({"value":1}), + Duration::from_millis(250), + ) + .unwrap(); + let result: serde_json::Value = read_frame(&mut right, Duration::from_millis(250)).unwrap(); + assert_eq!(result["value"], 1); + left.write_all(&10u32.to_be_bytes()).unwrap(); + left.write_all(b"{").unwrap(); + drop(left); + assert_eq!( + read_frame::(&mut right, Duration::from_millis(250)) + .unwrap_err() + .code, + ErrorCode::ResourceControlUnavailable + ); + } + + #[test] + fn framing_rejects_oversize_before_reading_payload_and_redacts_invalid_input() { + let (mut left, mut right) = UnixStream::pair().unwrap(); + left.write_all(&((MAX_FRAME_BYTES + 1) as u32).to_be_bytes()) + .unwrap(); + assert_eq!( + read_frame::(&mut right, Duration::from_millis(250)) + .unwrap_err() + .code, + ErrorCode::InvalidRequest + ); + let (mut left, mut right) = UnixStream::pair().unwrap(); + let secret = "DO-NOT-ECHO-THIS-INPUT"; + write_frame( + &mut left, + &serde_json::json!({"version":1,"request_id":1,"body":{"method":secret}}), + Duration::from_millis(250), + ) + .unwrap(); + let error = + read_frame::>(&mut right, Duration::from_millis(250)).unwrap_err(); + assert!(!error.to_string().contains(secret)); + } + + #[test] + fn framing_deadline_is_for_the_whole_frame_not_each_byte() { + let (mut left, mut right) = UnixStream::pair().unwrap(); + let writer = std::thread::spawn(move || { + for byte in [0, 0, 0, 2, b'{', b'}'] { + std::thread::sleep(Duration::from_millis(40)); + if left.write_all(&[byte]).is_err() { + break; + } + } + }); + let began = Instant::now(); + assert!(read_frame::(&mut right, Duration::from_millis(100)).is_err()); + assert!(began.elapsed() < Duration::from_secs(1)); + drop(right); + writer.join().unwrap(); + } +} diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs new file mode 100644 index 0000000..1bf9edb --- /dev/null +++ b/crates/client/src/lib.rs @@ -0,0 +1,112 @@ +//! Versioned, bounded local client. A successful handshake is not a workload lease. +pub mod connect; +pub mod credential; +pub mod framing; +pub mod peer; +pub mod protocol; + +use devguard_contract::{Compatibility, Error, ErrorCode, InstanceIdentity, Result}; +use protocol::*; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::time::Duration; + +pub struct Client { + stream: UnixStream, + next_id: u64, + pub hello: Hello, +} + +impl Client { + pub fn connect(path: &Path, expected_uid: u32, compatibility: Compatibility) -> Result { + let mut stream = connect::connect_timeout(path, Duration::from_millis(FRAME_DEADLINE_MS)) + .map_err(|_| unavailable())?; + let observed = peer::observe(&stream)?; + if observed.uid != expected_uid { + return Err(Error::new( + ErrorCode::Unauthorized, + "wrong authority peer UID", + )); + } + let response = exchange( + &mut stream, + 1, + Request::Hello { + compatibility: compatibility.clone(), + }, + )?; + let Response::Hello(hello) = response else { + return Err(unavailable()); + }; + // Caller/authority identity is corroborated by independent OS observations. + if hello.authority != observed + || hello.caller.pid != std::process::id() + || hello.caller.uid != unsafe { libc::geteuid() } + || hello.max_frame_bytes != MAX_FRAME_BYTES + || hello.frame_deadline_ms != FRAME_DEADLINE_MS + || hello.max_sessions != MAX_SESSIONS + { + return Err(Error::new( + ErrorCode::Unauthorized, + "authority identity or protocol bounds mismatch", + )); + } + compatibility.check(hello.protocol, &hello.capabilities)?; + Ok(Self { + stream, + next_id: 2, + hello, + }) + } + + pub fn authenticate(&mut self, credential: CallerCredential) -> Result { + match self.call(Request::Authenticate { credential })? { + Response::Authenticated { role } => Ok(role), + _ => Err(unavailable()), + } + } + pub fn status(&mut self) -> Result { + match self.call(Request::Status)? { + Response::Status(status) => Ok(status), + _ => Err(unavailable()), + } + } + pub fn register(&mut self, instance_id: String) -> Result { + match self.call(Request::Register { instance_id })? { + Response::Registered { instance } => Ok(instance), + _ => Err(unavailable()), + } + } + fn call(&mut self, request: Request) -> Result { + let id = self.next_id; + self.next_id = self.next_id.checked_add(1).ok_or_else(unavailable)?; + exchange(&mut self.stream, id, request) + } +} + +fn unavailable() -> Error { + Error::new( + ErrorCode::ResourceControlUnavailable, + "authority response unavailable or invalid; execution is not inferred", + ) +} +fn exchange(stream: &mut UnixStream, id: u64, body: Request) -> Result { + let timeout = Duration::from_millis(FRAME_DEADLINE_MS); + framing::write_frame( + stream, + &Frame { + version: WIRE_VERSION, + request_id: id, + body, + }, + timeout, + )?; + let reply: Frame = framing::read_frame(stream, timeout)?; + if reply.version != WIRE_VERSION || reply.request_id != id { + return Err(unavailable()); + } + match reply.body { + Response::Error(error) => Err(error.into()), + response => Ok(response), + } +} diff --git a/crates/client/src/peer.rs b/crates/client/src/peer.rs new file mode 100644 index 0000000..2eb3259 --- /dev/null +++ b/crates/client/src/peer.rs @@ -0,0 +1,74 @@ +use crate::protocol::PeerIdentity; +use devguard_contract::{Error, ErrorCode, Result}; +use std::os::fd::AsRawFd; +use std::os::unix::net::UnixStream; + +fn failed() -> Error { + Error::new( + ErrorCode::ResourceControlUnavailable, + "cannot observe local socket peer identity", + ) +} + +#[cfg(target_os = "macos")] +pub fn observe(stream: &UnixStream) -> Result { + let mut uid = 0; + let mut gid = 0; + let mut pid: libc::pid_t = 0; + let mut length = std::mem::size_of_val(&pid) as libc::socklen_t; + // SAFETY: the stream owns a valid socket and outputs have the native sizes. + if unsafe { libc::getpeereid(stream.as_raw_fd(), &mut uid, &mut gid) } != 0 + || unsafe { + libc::getsockopt( + stream.as_raw_fd(), + 0, + 0x002, + (&mut pid as *mut libc::pid_t).cast(), + &mut length, + ) + } != 0 + || length as usize != std::mem::size_of_val(&pid) + || pid <= 0 + { + return Err(failed()); + } + // Darwin sys/un.h: SOL_LOCAL=0, LOCAL_PEERPID=0x002. + Ok(PeerIdentity { + uid, + pid: pid as u32, + }) +} + +#[cfg(target_os = "linux")] +pub fn observe(stream: &UnixStream) -> Result { + // SAFETY: ucred is an output C struct filled by SO_PEERCRED. + let mut credential: libc::ucred = unsafe { std::mem::zeroed() }; + let mut length = std::mem::size_of_val(&credential) as libc::socklen_t; + // SAFETY: valid socket, live output struct and correctly sized length pointer. + if unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&mut credential as *mut libc::ucred).cast(), + &mut length, + ) + } != 0 + || length as usize != std::mem::size_of_val(&credential) + || credential.pid <= 0 + { + return Err(failed()); + } + Ok(PeerIdentity { + uid: credential.uid, + pid: credential.pid as u32, + }) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +pub fn observe(_: &UnixStream) -> Result { + Err(Error::new( + ErrorCode::ResourcePolicyUnsupported, + "OS peer inspection is unsupported", + )) +} diff --git a/crates/client/src/protocol.rs b/crates/client/src/protocol.rs new file mode 100644 index 0000000..ac0a053 --- /dev/null +++ b/crates/client/src/protocol.rs @@ -0,0 +1,127 @@ +use devguard_contract::{Capability, Compatibility, Error, ErrorCode, InstanceIdentity, Secret}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +pub const WIRE_VERSION: u32 = 1; +pub const MAX_FRAME_BYTES: usize = 64 * 1024; +pub const FRAME_DEADLINE_MS: u64 = 250; +pub const MAX_SESSIONS: usize = 32; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Frame { + pub version: u32, + pub request_id: u64, + pub body: T, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PeerIdentity { + pub uid: u32, + pub pid: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionRole { + Workload, + ControlService, + Administrator, +} + +/// These credentials authenticate a caller, never a launch helper or a peer PID. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum CallerCredential { + Consumer { + consumer_id: String, + generation: String, + secret: Secret, + }, + Administrator { + secret: Secret, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde( + tag = "method", + content = "params", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum Request { + Hello { + compatibility: Compatibility, + }, + Authenticate { + credential: CallerCredential, + }, + Status, + /// The server derives the process identity from its OS-observed peer. + /// C02 deliberately refuses this operation until C03 supplies native identity. + Register { + instance_id: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Hello { + pub protocol: u32, + pub authority: PeerIdentity, + pub caller: PeerIdentity, + pub capabilities: BTreeSet, + pub max_frame_bytes: usize, + pub frame_deadline_ms: u64, + pub max_sessions: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServiceStatus { + pub storage_validated: bool, + pub registration_ready: bool, + pub execution_ready: bool, + pub reason: String, + pub configuration_fingerprint: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WireError { + pub code: ErrorCode, + pub message: String, +} +impl From for WireError { + fn from(error: Error) -> Self { + Self { + code: error.code, + message: error.message, + } + } +} +impl From for Error { + fn from(error: WireError) -> Self { + Self { + code: error.code, + message: error.message, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde( + tag = "result", + content = "value", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum Response { + Hello(Hello), + Authenticated { role: SessionRole }, + Status(ServiceStatus), + Registered { instance: InstanceIdentity }, + Error(WireError), +} diff --git a/crates/client/tests/credentials.rs b/crates/client/tests/credentials.rs new file mode 100644 index 0000000..8c70ffa --- /dev/null +++ b/crates/client/tests/credentials.rs @@ -0,0 +1,154 @@ +//! Private-FD transport checks, not qualification of the future launch helper. +use devguard_client::credential::{read_owned, take_inherited, CredentialHandoff}; +use devguard_client::protocol::CallerCredential; +use devguard_contract::{ErrorCode, Secret}; +use std::io::Write; +use std::os::fd::{AsRawFd, OwnedFd}; +use std::os::unix::net::UnixStream; +use std::os::unix::process::CommandExt; +use std::process::{Child, Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +const TEST_SECRET: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const CHILD_MODE: &str = "DEVGUARD_CREDENTIAL_TEST_MODE"; +const CHILD_FD: &str = "DEVGUARD_CREDENTIAL_TEST_FD"; + +fn assert_closed(fd: i32) { + // SAFETY: F_GETFD only inspects the numeric descriptor; it does not consume it. + assert_eq!(unsafe { libc::fcntl(fd, libc::F_GETFD) }, -1); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EBADF) + ); +} + +fn wait_bounded(mut child: Child) -> Output { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if child.try_wait().unwrap().is_some() { + return child.wait_with_output().unwrap(); + } + if Instant::now() >= deadline { + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + panic!( + "credential subprocess exceeded its test deadline: {:?}", + output.status + ); + } + std::thread::sleep(Duration::from_millis(5)); + } +} + +fn child_command(test: &str, mode: &str) -> Command { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", test, "--ignored", "--nocapture"]) + .env(CHILD_MODE, mode) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command +} + +#[test] +fn credential_crosses_only_its_private_fd_and_is_closed_before_later_exec() { + let secret = Secret::new(TEST_SECRET.into()).unwrap(); + let handoff = CredentialHandoff::new(&secret).unwrap(); + let mut command = child_command("credential_receiver", "receiver"); + let raw = handoff.attach(&mut command); + command.env(CHILD_FD, raw.to_string()); + assert!(raw >= 3); + // SAFETY: the Command closure owns this descriptor until Command is dropped. + let parent_flags = unsafe { libc::fcntl(raw, libc::F_GETFD) }; + assert_ne!(parent_flags & libc::FD_CLOEXEC, 0); + assert!(!format!("{command:?}").contains(TEST_SECRET)); + let child = command.spawn().unwrap(); + drop(command); + assert_closed(raw); + let output = wait_bounded(child); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("payload has no credential FD")); + assert!(!String::from_utf8_lossy(&output.stdout).contains(TEST_SECRET)); + assert!(!String::from_utf8_lossy(&output.stderr).contains(TEST_SECRET)); +} + +#[test] +#[ignore = "invoked with a dedicated descriptor by its parent integration test"] +fn credential_receiver() { + assert_eq!(std::env::var(CHILD_MODE).unwrap(), "receiver"); + assert!(!std::env::args_os().any(|arg| arg.to_string_lossy().contains(TEST_SECRET))); + assert!(!std::env::vars_os().any(|(key, value)| { + key.to_string_lossy().contains(TEST_SECRET) || value.to_string_lossy().contains(TEST_SECRET) + })); + let fd: i32 = std::env::var(CHILD_FD).unwrap().parse().unwrap(); + // SAFETY: this subprocess exclusively owns the dedicated descriptor inherited + // through CredentialHandoff; no Rust object has taken its ownership. + let secret = unsafe { take_inherited(fd) }.unwrap(); + assert!(secret.expose() == TEST_SECRET); + assert!(!format!("{secret:?}").contains(TEST_SECRET)); + let credential = CallerCredential::Consumer { + consumer_id: "test-consumer".into(), + generation: "test-generation".into(), + secret, + }; + assert!(!format!("{credential:?}").contains(TEST_SECRET)); + assert_closed(fd); + drop(credential); + + // This exec occurs only after the transport has consumed and closed the FD. + // It checks descriptor hygiene, not a C05 permit or payload-start contract. + let error = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "credential_payload", "--ignored", "--nocapture"]) + .env(CHILD_MODE, "payload") + .exec(); + panic!("could not exec descriptor-check payload: {error}"); +} + +#[test] +#[ignore = "exec target of the private-FD integration test"] +fn credential_payload() { + assert_eq!(std::env::var(CHILD_MODE).unwrap(), "payload"); + let fd: i32 = std::env::var(CHILD_FD).unwrap().parse().unwrap(); + assert_closed(fd); + println!("payload has no credential FD"); +} + +#[test] +fn malformed_credentials_are_redacted_and_always_close_the_owned_fd() { + let malformed = [ + Vec::new(), + TEST_SECRET.as_bytes()[..63].to_vec(), + vec![b'g'; 64], + vec![0xff; 64], + [TEST_SECRET.as_bytes(), b"extra"].concat(), + ]; + for bytes in malformed { + let (reader, mut writer) = UnixStream::pair().unwrap(); + writer.write_all(&bytes).unwrap(); + drop(writer); + let raw = reader.as_raw_fd(); + let owned: OwnedFd = reader.into(); + let error = read_owned(owned).unwrap_err(); + assert_eq!(error.code, ErrorCode::Unauthorized); + assert!(!error.to_string().contains(TEST_SECRET)); + assert!(!format!("{error:?}").contains(TEST_SECRET)); + assert_closed(raw); + } +} + +#[test] +fn a_full_secret_without_writer_eof_expires_and_closes_the_fd() { + let (reader, mut writer) = UnixStream::pair().unwrap(); + writer.write_all(TEST_SECRET.as_bytes()).unwrap(); + let raw = reader.as_raw_fd(); + let began = Instant::now(); + let error = read_owned(reader.into()).unwrap_err(); + assert_eq!(error.code, ErrorCode::Unauthorized); + assert!(began.elapsed() < Duration::from_secs(2)); + assert_closed(raw); + drop(writer); +} diff --git a/crates/client/tests/native_peer.rs b/crates/client/tests/native_peer.rs new file mode 100644 index 0000000..0494062 --- /dev/null +++ b/crates/client/tests/native_peer.rs @@ -0,0 +1,227 @@ +#![cfg(any(target_os = "macos", target_os = "linux"))] + +use devguard_client::protocol::{ + Frame, Hello, Request, Response, FRAME_DEADLINE_MS, MAX_FRAME_BYTES, MAX_SESSIONS, WIRE_VERSION, +}; +use devguard_client::Client; +use devguard_client::{connect::connect_timeout, framing, peer, protocol::PeerIdentity}; +use devguard_contract::{Compatibility, ErrorCode, PROTOCOL_VERSION}; +use std::collections::BTreeSet; +use std::os::fd::AsRawFd; +use std::os::unix::net::UnixListener; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const CHILD_MODE: &str = "DEVGUARD_PEER_TEST_MODE"; +const ENDPOINT: &str = "DEVGUARD_PEER_TEST_ENDPOINT"; +const PARENT_PID: &str = "DEVGUARD_PEER_TEST_PARENT_PID"; + +#[test] +fn both_socket_ends_observe_the_other_process_without_caller_supplied_identity() { + // Keep AF_UNIX paths short on macOS, independent of its long default TMPDIR. + let directory = tempfile::Builder::new() + .prefix("dg-peer-") + .tempdir_in("/tmp") + .unwrap(); + let endpoint = directory.path().join("peer.sock"); + let listener = UnixListener::bind(&endpoint).unwrap(); + listener.set_nonblocking(true).unwrap(); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "peer_connector", "--ignored", "--nocapture"]) + .env(CHILD_MODE, "connector") + .env(ENDPOINT, &endpoint) + .env(PARENT_PID, std::process::id().to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let child_pid = child.id(); + assert_ne!(child_pid, std::process::id()); + let deadline = Instant::now() + Duration::from_secs(5); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if child.try_wait().unwrap().is_some() || Instant::now() >= deadline { + let _ = child.kill(); + let output = child.wait_with_output().unwrap(); + panic!( + "peer child failed before connect: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("test listener failed: {error}"), + } + }; + stream.set_nonblocking(false).unwrap(); + let observed = peer::observe(&stream).unwrap(); + assert_eq!(observed.pid, child_pid); + // SAFETY: geteuid has no arguments or memory ownership effects. + assert_eq!(observed.uid, unsafe { libc::geteuid() }); + let child_observation: PeerIdentity = + framing::read_frame(&mut stream, Duration::from_secs(1)).unwrap(); + assert_eq!(child_observation.pid, std::process::id()); + assert_eq!(child_observation.uid, observed.uid); + // Keep the connecting process alive until both native observations finish. + framing::write_frame(&mut stream, &true, Duration::from_secs(1)).unwrap(); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("peer subprocess exceeded its test deadline"); + } + std::thread::sleep(Duration::from_millis(5)); + } + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +#[ignore = "spawned by the native peer integration test"] +fn peer_connector() { + assert_eq!(std::env::var(CHILD_MODE).unwrap(), "connector"); + let endpoint = std::env::var_os(ENDPOINT).unwrap(); + let mut stream = + connect_timeout(std::path::Path::new(&endpoint), Duration::from_secs(1)).unwrap(); + let raw = stream.as_raw_fd(); + assert!(raw >= 3); + // SAFETY: stream owns this live socket throughout both non-mutating calls. + assert_ne!( + unsafe { libc::fcntl(raw, libc::F_GETFD) } & libc::FD_CLOEXEC, + 0 + ); + assert_eq!( + unsafe { libc::fcntl(raw, libc::F_GETFL) } & libc::O_NONBLOCK, + 0 + ); + let observed = peer::observe(&stream).unwrap(); + assert_eq!( + observed.pid, + std::env::var(PARENT_PID).unwrap().parse::().unwrap() + ); + framing::write_frame(&mut stream, &observed, Duration::from_secs(1)).unwrap(); + assert!(framing::read_frame::(&mut stream, Duration::from_secs(1)).unwrap()); +} + +#[test] +fn invalid_endpoint_or_connect_budget_is_rejected_before_connecting() { + for (endpoint, budget) in [ + ( + std::path::Path::new("relative.sock"), + Duration::from_millis(250), + ), + (std::path::Path::new("/tmp/unused.sock"), Duration::ZERO), + ( + std::path::Path::new("/tmp/unused.sock"), + Duration::from_secs(6), + ), + ] { + assert_eq!( + connect_timeout(endpoint, budget).unwrap_err().kind(), + std::io::ErrorKind::InvalidInput + ); + } + let long_endpoint = format!("/tmp/{}", "a".repeat(200)); + assert_eq!( + connect_timeout( + std::path::Path::new(&long_endpoint), + Duration::from_millis(250) + ) + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); +} + +#[test] +fn client_rejects_new_wire_versions_reply_identity_and_forged_peer_information() { + // Frame deserialization preserves an integer version; Client must explicitly + // reject a mismatch before accepting the response as a successful handshake. + for scenario in [ + "version", + "request_id", + "authority_pid", + "caller_pid", + "bounds", + ] { + let directory = tempfile::Builder::new() + .prefix("dg-wire-") + .tempdir_in("/tmp") + .unwrap(); + let endpoint = directory.path().join("wire.sock"); + let listener = UnixListener::bind(&endpoint).unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request: Frame = + framing::read_frame(&mut stream, Duration::from_secs(1)).unwrap(); + assert!(matches!(request.body, Request::Hello { .. })); + let observed = peer::observe(&stream).unwrap(); + let mut hello = Hello { + protocol: PROTOCOL_VERSION, + authority: PeerIdentity { + uid: observed.uid, + pid: std::process::id(), + }, + caller: observed, + capabilities: BTreeSet::new(), + max_frame_bytes: MAX_FRAME_BYTES, + frame_deadline_ms: FRAME_DEADLINE_MS, + max_sessions: MAX_SESSIONS, + }; + if scenario == "authority_pid" { + hello.authority.pid += 1; + } + if scenario == "caller_pid" { + hello.caller.pid += 1; + } + if scenario == "bounds" { + hello.max_sessions += 1; + } + framing::write_frame( + &mut stream, + &Frame { + version: if scenario == "version" { + WIRE_VERSION + 1 + } else { + WIRE_VERSION + }, + request_id: if scenario == "request_id" { + request.request_id + 1 + } else { + request.request_id + }, + body: Response::Hello(hello), + }, + Duration::from_secs(1), + ) + .unwrap(); + }); + // SAFETY: geteuid only observes this process's effective UID. + let error = Client::connect( + &endpoint, + unsafe { libc::geteuid() }, + Compatibility { + minimum_protocol: PROTOCOL_VERSION, + maximum_protocol: PROTOCOL_VERSION, + required: BTreeSet::new(), + }, + ) + .err() + .expect("invalid handshake was accepted"); + assert_eq!( + error.code, + if matches!(scenario, "version" | "request_id") { + ErrorCode::ResourceControlUnavailable + } else { + ErrorCode::Unauthorized + } + ); + server.join().unwrap(); + } +} diff --git a/crates/client/tests/wire_compatibility.rs b/crates/client/tests/wire_compatibility.rs new file mode 100644 index 0000000..85bbd4f --- /dev/null +++ b/crates/client/tests/wire_compatibility.rs @@ -0,0 +1,110 @@ +use devguard_client::protocol::{Frame, Request, Response, WIRE_VERSION}; +use serde_json::{json, Value}; + +fn current_hello() -> Value { + json!({ + "version": 1, + "request_id": 1, + "body": { + "method": "hello", + "params": { + "compatibility": { + "minimum_protocol": 1, + "maximum_protocol": 1, + "required": [] + } + } + } + }) +} + +#[test] +fn current_request_fixture_decodes_but_additive_fields_do_not_imply_compatibility() { + let current = current_hello(); + let frame: Frame = serde_json::from_value(current.clone()).unwrap(); + assert_eq!(frame.version, WIRE_VERSION); + assert_eq!(frame.request_id, 1); + assert!(matches!(frame.body, Request::Hello { .. })); + for pointer in ["", "/body", "/body/params", "/body/params/compatibility"] { + let mut future = current.clone(); + future + .pointer_mut(pointer) + .unwrap() + .as_object_mut() + .unwrap() + .insert("future_field".into(), json!(true)); + assert!( + serde_json::from_value::>(future).is_err(), + "accepted {pointer}" + ); + } + let mut unsupported_capability = current; + unsupported_capability["body"]["params"]["compatibility"]["required"] = + json!(["future_capability"]); + assert!(serde_json::from_value::>(unsupported_capability).is_err()); +} + +#[test] +fn caller_cannot_add_a_peer_identity_or_change_the_credential_role_shape() { + let current = json!({ + "version": 1, + "request_id": 2, + "body": {"method": "authenticate", "params": {"credential": { + "kind": "consumer", "consumer_id": "consumer", "generation": "generation", + "secret": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + }}} + }); + assert!(serde_json::from_value::>(current.clone()).is_ok()); + for field in ["uid", "pid", "role", "permit", "authority"] { + let mut changed = current.clone(); + changed["body"]["params"]["credential"][field] = json!(1); + assert!( + serde_json::from_value::>(changed).is_err(), + "accepted {field}" + ); + } + let mut helper_role = current; + helper_role["body"]["params"]["credential"]["kind"] = json!("helper"); + assert!(serde_json::from_value::>(helper_role).is_err()); + let forged_register = json!({"version":1,"request_id":3,"body":{ + "method":"register","params":{"instance_id":"instance","pid":123}}}); + assert!(serde_json::from_value::>(forged_register).is_err()); +} + +#[test] +fn response_fixtures_reject_additions_in_envelope_status_identity_and_error() { + let hello = json!({"version":1,"request_id":1,"body":{"result":"hello","value":{ + "protocol":1,"authority":{"uid":501,"pid":100},"caller":{"uid":501,"pid":200}, + "capabilities":[],"max_frame_bytes":65536,"frame_deadline_ms":250,"max_sessions":32}}}); + for pointer in [ + "", + "/body", + "/body/value", + "/body/value/authority", + "/body/value/caller", + ] { + assert!(serde_json::from_value::>(hello.clone()).is_ok()); + let mut future = hello.clone(); + future + .pointer_mut(pointer) + .unwrap() + .as_object_mut() + .unwrap() + .insert("future_field".into(), json!(true)); + assert!( + serde_json::from_value::>(future).is_err(), + "accepted {pointer}" + ); + } + let status = json!({"version":1,"request_id":3,"body":{"result":"status","value":{ + "storage_validated":true,"registration_ready":false,"execution_ready":false, + "reason":"native evidence is unavailable","configuration_fingerprint":"fixture"}}}); + let error = json!({"version":1,"request_id":3,"body":{"result":"error","value":{ + "code":"resource_control_unavailable","message":"not ready"}}}); + for current in [status, error] { + assert!(serde_json::from_value::>(current.clone()).is_ok()); + let mut future = current; + future["body"]["value"]["future_field"] = json!(true); + assert!(serde_json::from_value::>(future).is_err()); + } +} diff --git a/crates/core/src/authority.rs b/crates/core/src/authority.rs index 2a3b2c9..7ebc4a9 100644 --- a/crates/core/src/authority.rs +++ b/crates/core/src/authority.rs @@ -6,7 +6,7 @@ use devguard_contract::*; use rusqlite::{params, OptionalExtension, Transaction}; use std::fs::{File, OpenOptions}; use std::os::fd::AsRawFd; -use std::os::unix::fs::OpenOptionsExt; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; use std::path::Path; use uuid::Uuid; @@ -69,13 +69,15 @@ pub struct Authority { _authority_lock: File, } -impl Authority { - pub fn initialize_journal(path: &Path) -> Result<()> { - Journal::initialize(path) - } +/// Exclusive, validated storage before a real boot clock/backend is available. +/// Opening storage neither recovers attempts nor grants an execution capability. +pub struct AuthorityStorage { + journal: Journal, + authority_lock: File, +} - pub fn open(path: &Path, policy: Policy, backend: B, clock: C) -> Result { - policy.validate()?; +impl AuthorityStorage { + fn lock(path: &Path) -> Result { let parent = path.parent().ok_or_else(|| { Error::new( ErrorCode::JournalInvalid, @@ -86,10 +88,23 @@ impl Authority { .write(true) .create(true) .truncate(false) - .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) .mode(0o600) .open(parent.join("authority.lock")) .map_err(|_| Error::new(ErrorCode::JournalInvalid, "cannot open authority lock"))?; + let metadata = lock + .metadata() + .map_err(|_| Error::new(ErrorCode::JournalInvalid, "cannot observe authority lock"))?; + if !metadata.is_file() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o077 != 0 + || metadata.nlink() != 1 + { + return Err(Error::new( + ErrorCode::JournalInvalid, + "authority lock must be a private owned regular file", + )); + } // SAFETY: flock operates on the valid File descriptor; File retains ownership. if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { return Err(Error::new( @@ -97,9 +112,56 @@ impl Authority { "another authority owns this state directory", )); } + Ok(lock) + } + + pub fn open(path: &Path) -> Result { + Self::open_locked(path, Self::lock(path)?) + } + + /// Explicit first bootstrap only. Existing/missing/corrupt state is never reset. + pub fn initialize(path: &Path) -> Result { + let lock = Self::lock(path)?; + Journal::initialize(path)?; + Self::open_locked(path, lock) + } + + fn open_locked(path: &Path, authority_lock: File) -> Result { let mut journal = Journal::open(path)?; + journal.transaction(journal::validate_index)?; + Ok(Self { + journal, + authority_lock, + }) + } +} + +impl Authority { + pub fn initialize_journal(path: &Path) -> Result<()> { + Journal::initialize(path) + } + + pub fn open(path: &Path, policy: Policy, backend: B, clock: C) -> Result { + policy.validate()?; + Self::from_storage(AuthorityStorage::open(path)?, policy, backend, clock) + } + + /// Activate exclusively held storage using actual backend/boot observations. + pub fn from_storage( + storage: AuthorityStorage, + policy: Policy, + backend: B, + clock: C, + ) -> Result { + policy.validate()?; + let AuthorityStorage { + mut journal, + authority_lock, + } = storage; let now = clock.now(); journal.transaction(|tx| { + // Storage may have waited for host readiness. Recheck atomically with + // recovery so intervening corruption cannot hide a charged attempt. journal::validate_index(tx)?; validate_registration_policies(tx, &policy)?; journal::expire_prepared(tx, &now)?; @@ -122,7 +184,7 @@ impl Authority { backend, clock, pressure: PressureController::default(), - _authority_lock: lock, + _authority_lock: authority_lock, }) } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index b560ad7..8a85275 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -6,7 +6,9 @@ mod journal; mod policy; mod pressure; -pub use authority::{Authority, LaunchDecision, Principal, Registration, RunDecision, TrustedPeer}; +pub use authority::{ + Authority, AuthorityStorage, LaunchDecision, Principal, Registration, RunDecision, TrustedPeer, +}; pub use policy::{ConsumerDefinition, ConsumerRole, Policy}; pub use pressure::{MemoryPressure, PressureController, PressureSample, PressureState}; diff --git a/crates/core/tests/authority_contract.rs b/crates/core/tests/authority_contract.rs index e7fb601..2eb6b31 100644 --- a/crates/core/tests/authority_contract.rs +++ b/crates/core/tests/authority_contract.rs @@ -5,6 +5,29 @@ use rusqlite::Connection; use std::sync::{Arc, Barrier, Mutex}; use support::*; +#[test] +fn storage_activation_revalidates_accounting_in_the_recovery_transaction() { + let h = Harness::new(); + let (mut authority, principal) = h.ready(); + h.prepare(&mut authority, &principal, "activation-gap"); + drop(authority); + let storage = AuthorityStorage::open(&h.path).unwrap(); + let fault = Connection::open(&h.path).unwrap(); + fault.execute("UPDATE attempts SET charged=0", []).unwrap(); + assert!(matches!( + TestAuthority::from_storage( + storage, + h.policy.clone(), + h.backend.clone(), + h.clock.clone() + ), + Err(Error { + code: ErrorCode::JournalInvalid, + .. + }) + )); +} + #[test] fn admission_reply_loss_and_policy_change_replay_one_durable_reservation() { let mut h = Harness::new(); diff --git a/crates/core/tests/authority_storage.rs b/crates/core/tests/authority_storage.rs new file mode 100644 index 0000000..dbe3d6f --- /dev/null +++ b/crates/core/tests/authority_storage.rs @@ -0,0 +1,137 @@ +use devguard_contract::ErrorCode; +use devguard_core::AuthorityStorage; +use rusqlite::Connection; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{symlink, MetadataExt, OpenOptionsExt, PermissionsExt}; + +fn private_lock(path: &std::path::Path) { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .unwrap(); + file.write_all(b"preserved lock identity").unwrap(); +} + +#[test] +fn storage_is_exclusive_without_inventing_a_boot_clock() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("state.sqlite"); + let first = AuthorityStorage::initialize(&path).unwrap(); + assert!( + matches!(AuthorityStorage::open(&path), Err(e) if e.code == ErrorCode::ResourceControlUnavailable) + ); + drop(first); + let reopened = AuthorityStorage::open(&path).unwrap(); + let connection = Connection::open(&path).unwrap(); + let count: u32 = connection + .query_row("SELECT count(*) FROM attempts", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + drop(reopened); + assert!(AuthorityStorage::initialize(&path).is_err()); +} + +#[test] +fn storage_open_does_not_initialize_or_repair_journals() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("state.sqlite"); + assert!(AuthorityStorage::open(&path).is_err()); + assert!(!path.exists()); + std::fs::write(&path, b"corrupt journal evidence").unwrap(); + assert!(AuthorityStorage::open(&path).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"corrupt journal evidence"); +} + +#[test] +fn storage_rejects_unknown_schema_without_changing_it() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("state.sqlite"); + drop(AuthorityStorage::initialize(&path).unwrap()); + let connection = Connection::open(&path).unwrap(); + connection + .execute("UPDATE metadata SET value='2' WHERE name='schema'", []) + .unwrap(); + assert!(matches!(AuthorityStorage::open(&path), Err(e) if e.code == ErrorCode::JournalInvalid)); + let schema: String = connection + .query_row("SELECT value FROM metadata WHERE name='schema'", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(schema, "2"); +} + +#[test] +fn storage_bootstrap_rejects_a_fifo_lock_without_creating_a_journal() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("state.sqlite"); + let lock = directory.path().join("authority.lock"); + let native = std::ffi::CString::new(lock.as_os_str().as_bytes()).unwrap(); + // SAFETY: native is a live, terminated pathname in this private test directory. + assert_eq!(unsafe { libc::mkfifo(native.as_ptr(), 0o600) }, 0); + // Keep a nonblocking reader open so this regression also stays bounded if a + // future change accidentally removes O_NONBLOCK from the write-side open. + let _reader = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(&lock) + .unwrap(); + assert!( + matches!(AuthorityStorage::initialize(&path), Err(e) if e.code == ErrorCode::JournalInvalid) + ); + assert!(!path.exists()); + assert!(fs::symlink_metadata(&lock).is_ok()); +} + +#[test] +fn storage_bootstrap_rejects_shared_linked_and_symlink_locks() { + for kind in ["shared", "hard_link", "symlink"] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("state.sqlite"); + let lock = directory.path().join("authority.lock"); + let source = directory.path().join("original.lock"); + match kind { + "shared" => { + private_lock(&lock); + fs::set_permissions(&lock, fs::Permissions::from_mode(0o644)).unwrap(); + } + "hard_link" => { + private_lock(&source); + fs::hard_link(&source, &lock).unwrap(); + } + "symlink" => { + private_lock(&source); + symlink(&source, &lock).unwrap(); + } + _ => unreachable!(), + } + let before = fs::symlink_metadata(&lock).unwrap(); + assert!( + matches!(AuthorityStorage::initialize(&path), Err(e) if e.code == ErrorCode::JournalInvalid), + "unsafe {kind} lock was accepted" + ); + assert!(!path.exists(), "unsafe {kind} lock created a journal"); + let after = fs::symlink_metadata(&lock).unwrap(); + assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino())); + assert_eq!(fs::read(&lock).unwrap(), b"preserved lock identity"); + assert_eq!(before.mode(), after.mode()); + } +} + +#[test] +fn storage_bootstrap_and_reopen_preserve_an_existing_private_lock() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("state.sqlite"); + let lock = directory.path().join("authority.lock"); + private_lock(&lock); + let before = fs::metadata(&lock).unwrap(); + drop(AuthorityStorage::initialize(&path).unwrap()); + drop(AuthorityStorage::open(&path).unwrap()); + let after = fs::metadata(&lock).unwrap(); + assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino())); + assert_eq!(fs::read(&lock).unwrap(), b"preserved lock identity"); + assert_eq!(after.mode() & 0o777, 0o600); +} diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml new file mode 100644 index 0000000..b637737 --- /dev/null +++ b/crates/daemon/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "devguard-daemon" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true +license.workspace = true +repository.workspace = true +description = "Canonical local authority configuration and service boundary" + +[[bin]] +name = "devguardd" +path = "src/main.rs" + +[dependencies] +devguard-contract = { path = "../contract" } +devguard-core = { path = "../core" } +devguard-client = { path = "../client" } +libc.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true +uuid.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/daemon/src/config.rs b/crates/daemon/src/config.rs new file mode 100644 index 0000000..1310419 --- /dev/null +++ b/crates/daemon/src/config.rs @@ -0,0 +1,461 @@ +use crate::paths::{read_private, write_new_private, AuthorityPaths}; +use devguard_contract::{validate_digest, validate_id, Budget, Error, ErrorCode, Result, Secret}; +use devguard_core::{AuthorityStorage, ConsumerRole}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use uuid::Uuid; + +pub const CONFIG_SCHEMA: u32 = 1; +pub const CONFIG_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConsumerConfig { + pub generation: String, + pub role: ConsumerRole, + pub credential_sha256: String, + pub max_instances: u32, + pub control_reservation: Budget, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectRegistration { + pub root: PathBuf, +} + +/// Operator-only policy. There are deliberately no state/socket/HOME overrides. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostConfig { + pub schema: u32, + pub policy_revision: String, + pub default_profile: String, + pub admin_credential_sha256: String, + /// An operator accounting ceiling, not a macOS kernel task limit. + pub task_capacity: u64, + pub task_headroom: u64, + pub system_tasks: u64, + #[serde(default)] + pub additional_headroom: Budget, + #[serde(default)] + pub consumers: BTreeMap, + #[serde(default)] + pub projects: BTreeMap, +} + +fn invalid(message: &'static str) -> Error { + Error::new(ErrorCode::InvalidRequest, message) +} + +impl HostConfig { + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > CONFIG_BYTES { + return Err(invalid("operator configuration exceeds byte limit")); + } + let text = + std::str::from_utf8(bytes).map_err(|_| invalid("configuration must be UTF-8"))?; + // Parser errors can contain source text. Never echo configuration/credentials. + let config: Self = toml::from_str(text) + .map_err(|_| invalid("invalid or unsupported operator configuration"))?; + config.validate()?; + Ok(config) + } + + pub fn load(paths: &AuthorityPaths) -> Result { + Self::parse(&read_private(&paths.config(), paths.uid(), CONFIG_BYTES)?) + } + + pub fn validate(&self) -> Result<()> { + if self.schema != CONFIG_SCHEMA || self.default_profile != "interactive" { + return Err(Error::new( + ErrorCode::ResourcePolicyUnsupported, + "unsupported configuration schema or profile", + )); + } + validate_id(&self.policy_revision)?; + validate_digest(&self.admin_credential_sha256)?; + if self.system_tasks < devguard_client::protocol::MAX_SESSIONS as u64 + 16 { + return Err(invalid( + "system task accounting must cover bounded sessions and CLI control", + )); + } + if self.task_capacity == 0 + || self + .task_headroom + .checked_add(self.system_tasks) + .is_none_or(|n| n >= self.task_capacity) + { + return Err(invalid( + "task accounting must leave positive workload capacity", + )); + } + if self.consumers.len() > 64 || self.projects.len() > 256 { + return Err(invalid("configuration inventory exceeds supported bounds")); + } + let mut reservations = Budget::ZERO; + let mut credentials = BTreeSet::from([self.admin_credential_sha256.clone()]); + for (id, consumer) in &self.consumers { + validate_id(id)?; + validate_id(&consumer.generation)?; + validate_digest(&consumer.credential_sha256)?; + if consumer.max_instances == 0 || consumer.max_instances > 64 { + return Err(invalid("consumer instance count must be 1..64")); + } + if !credentials.insert(consumer.credential_sha256.clone()) { + return Err(invalid( + "credentials must differ across administration and consumers", + )); + } + match consumer.role { + ConsumerRole::Workload if consumer.control_reservation != Budget::ZERO => { + return Err(invalid("workload cannot reserve control-service capacity")) + } + ConsumerRole::ControlService => consumer.control_reservation.validate_workload()?, + _ => {} + } + reservations = reservations.checked_add( + consumer + .control_reservation + .checked_mul(consumer.max_instances)?, + )?; + } + for (id, project) in &self.projects { + validate_id(id)?; + if !project.root.is_absolute() + || project + .root + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(invalid( + "registered project roots must be absolute without parent traversal", + )); + } + } + Ok(()) + } + + pub fn fingerprint(&self) -> Result { + Ok(devguard_contract::digest_bytes( + &serde_json::to_vec(self).map_err(|_| invalid("configuration encoding failed"))?, + )) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum AdapterSelection { + Auto, + Generic, + Cargo, + CargoPipeline, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectSettings { + pub schema: u32, + pub project_id: String, + pub profile: String, + pub adapter: AdapterSelection, + pub limits: Option, +} + +impl ProjectSettings { + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > CONFIG_BYTES { + return Err(invalid("project configuration exceeds byte limit")); + } + let settings: Self = toml::from_str( + std::str::from_utf8(bytes) + .map_err(|_| invalid("project configuration must be UTF-8"))?, + ) + .map_err(|_| { + invalid("invalid project configuration; authority fields are not permitted") + })?; + if settings.schema != CONFIG_SCHEMA || settings.profile != "interactive" { + return Err(Error::new( + ErrorCode::ResourcePolicyUnsupported, + "unsupported project schema/profile", + )); + } + validate_id(&settings.project_id)?; + if let Some(limits) = settings.limits { + limits.validate_workload()?; + } + Ok(settings) + } + + pub fn restricted_budget(&self, operator_limit: Budget) -> Result { + let result = self.limits.unwrap_or(operator_limit); + if !result.fits(operator_limit) { + return Err(invalid("project limits may only tighten operator policy")); + } + Ok(result) + } +} + +fn new_secret() -> Result { + Secret::new(format!( + "{}{}", + Uuid::new_v4().simple(), + Uuid::new_v4().simple() + )) +} + +/// Explicit bootstrap; no ordinary serve/restart path calls this function. +/// A partial failure is preserved for explicit repair, never silently reset. +pub fn initialize(paths: &AuthorityPaths) -> Result { + paths.prepare_bootstrap()?; + let files = [ + paths.config(), + paths.cli_credential(), + paths.admin_credential(), + paths.journal(), + ]; + if files.iter().any(|p| std::fs::symlink_metadata(p).is_ok()) { + return Err(invalid( + "bootstrap requires absent configuration, credentials and journal", + )); + } + // Acquire canonical storage ownership before creating any credentials. + let storage = AuthorityStorage::initialize(&paths.journal())?; + let cli = new_secret()?; + let admin = new_secret()?; + let config = HostConfig { + schema: CONFIG_SCHEMA, + policy_revision: "interactive-v1".into(), + default_profile: "interactive".into(), + admin_credential_sha256: admin.digest(), + task_capacity: 256, + task_headroom: 64, + system_tasks: 48, + additional_headroom: Budget::ZERO, + consumers: BTreeMap::from([( + "dev-cli".into(), + ConsumerConfig { + generation: Uuid::new_v4().to_string(), + role: ConsumerRole::Workload, + credential_sha256: cli.digest(), + max_instances: 8, + control_reservation: Budget::ZERO, + }, + )]), + projects: BTreeMap::new(), + }; + config.validate()?; + write_new_private(&paths.cli_credential(), cli.expose().as_bytes())?; + write_new_private(&paths.admin_credential(), admin.expose().as_bytes())?; + let encoded = + toml::to_string_pretty(&config).map_err(|_| invalid("configuration encoding failed"))?; + write_new_private(&paths.config(), encoded.as_bytes())?; + drop(storage); + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Barrier}; + + fn fixture() -> (tempfile::TempDir, AuthorityPaths) { + let directory = tempfile::Builder::new() + .prefix("dg-config-") + .tempdir_in(if cfg!(target_os = "macos") { + "/private/tmp" + } else { + "/tmp" + }) + .unwrap(); + let paths = AuthorityPaths::fixture(directory.path()); + (directory, paths) + } + + #[test] + fn authority_bootstrap_keeps_roles_and_files_separate() { + let (_dir, paths) = fixture(); + let config = initialize(&paths).unwrap(); + paths.validate_existing().unwrap(); + let cli = read_private(&paths.cli_credential(), paths.uid(), 64).unwrap(); + let admin = read_private(&paths.admin_credential(), paths.uid(), 64).unwrap(); + assert_ne!(cli, admin); + assert_eq!( + devguard_contract::digest_bytes(&cli), + config.consumers["dev-cli"].credential_sha256 + ); + assert!(!std::fs::read_to_string(paths.config()) + .unwrap() + .contains(std::str::from_utf8(&cli).unwrap())); + assert_eq!( + config.fingerprint().unwrap(), + HostConfig::load(&paths).unwrap().fingerprint().unwrap() + ); + assert!(initialize(&paths).is_err()); + } + + #[test] + fn authority_configuration_rejects_path_overrides_and_unknown_versions() { + let (_dir, paths) = fixture(); + let config = initialize(&paths).unwrap(); + let text = toml::to_string_pretty(&config).unwrap(); + for field in [ + "state_dir", + "socket", + "home", + "test_capacity", + "parent_lease", + ] { + assert!( + HostConfig::parse(format!("{field} = '/tmp/other'\n{text}").as_bytes()).is_err() + ); + } + let future = text.replacen("schema = 1", "schema = 2", 1); + assert_eq!( + HostConfig::parse(future.as_bytes()).unwrap_err().code, + ErrorCode::ResourcePolicyUnsupported + ); + let mut invalid = config.clone(); + invalid + .consumers + .get_mut("dev-cli") + .unwrap() + .control_reservation + .cpu_milli = 1; + assert!(invalid.validate().is_err()); + invalid = config.clone(); + invalid + .consumers + .get_mut("dev-cli") + .unwrap() + .credential_sha256 = config.admin_credential_sha256; + assert!(invalid.validate().is_err()); + assert!(HostConfig::parse(&vec![b'a'; CONFIG_BYTES + 1]).is_err()); + } + + #[test] + fn authority_bootstrap_race_has_one_owner_without_credential_overwrite() { + let (_dir, paths) = fixture(); + paths.prepare_bootstrap().unwrap(); + let barrier = Arc::new(Barrier::new(2)); + let threads: Vec<_> = (0..2) + .map(|_| { + let p = paths.clone(); + let b = barrier.clone(); + std::thread::spawn(move || { + b.wait(); + initialize(&p) + }) + }) + .collect(); + let results: Vec<_> = threads.into_iter().map(|t| t.join().unwrap()).collect(); + assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1); + HostConfig::load(&paths).unwrap(); + paths.validate_existing().unwrap(); + } + + #[test] + fn authority_configuration_rejects_prior_insufficient_session_reserves() { + let (_dir, paths) = fixture(); + let config = initialize(&paths).unwrap(); + assert_eq!(config.system_tasks, 48); + for system_tasks in [16, 47] { + let mut prior = config.clone(); + prior.system_tasks = system_tasks; + let encoded = toml::to_string_pretty(&prior).unwrap(); + assert_eq!( + HostConfig::parse(encoded.as_bytes()).unwrap_err().code, + ErrorCode::InvalidRequest, + "configuration with reserve {system_tasks} was accepted" + ); + } + for system_tasks in [48, 49] { + let mut sufficient = config.clone(); + sufficient.system_tasks = system_tasks; + let encoded = toml::to_string_pretty(&sufficient).unwrap(); + assert_eq!( + HostConfig::parse(encoded.as_bytes()).unwrap().system_tasks, + system_tasks + ); + } + } + + #[test] + fn authority_configuration_requires_distinct_admin_and_consumer_credentials() { + let (_dir, paths) = fixture(); + let config = initialize(&paths).unwrap(); + let mut control = config.consumers["dev-cli"].clone(); + control.generation = "independent-generation".into(); + control.role = ConsumerRole::ControlService; + control.control_reservation = Budget { + cpu_milli: 100, + memory_bytes: 64 * 1024 * 1024, + tasks: 4, + }; + let mut duplicated = config.clone(); + duplicated + .consumers + .insert("control-service".into(), control.clone()); + assert_eq!( + duplicated.validate().unwrap_err().code, + ErrorCode::InvalidRequest + ); + + control.credential_sha256 = config.admin_credential_sha256.clone(); + duplicated + .consumers + .insert("control-service".into(), control.clone()); + assert_eq!( + duplicated.validate().unwrap_err().code, + ErrorCode::InvalidRequest + ); + + control.credential_sha256 = new_secret().unwrap().digest(); + let mut distinct = config; + distinct.consumers.insert("control-service".into(), control); + let encoded = toml::to_string_pretty(&distinct).unwrap(); + assert_eq!( + HostConfig::parse(encoded.as_bytes()) + .unwrap() + .consumers + .len(), + 2 + ); + } + + #[test] + fn authority_project_settings_cannot_grant_roles_or_relax_limits() { + let text="schema=1\nproject_id='devguard-dev'\nprofile='interactive'\nadapter='cargo'\n[limits]\ncpu_milli=1000\nmemory_bytes=2147483648\ntasks=32\n"; + let settings = ProjectSettings::parse(text.as_bytes()).unwrap(); + let ceiling = Budget { + cpu_milli: 2000, + memory_bytes: 4 * 1024 * 1024 * 1024, + tasks: 64, + }; + assert_eq!(settings.restricted_budget(ceiling).unwrap().cpu_milli, 1000); + assert!(settings + .restricted_budget(Budget { + cpu_milli: 500, + ..ceiling + }) + .is_err()); + for field in [ + "role", + "credential_sha256", + "socket", + "capacity", + "consumer_id", + ] { + assert!(ProjectSettings::parse( + format!("{field}='control_service'\n{text}").as_bytes() + ) + .is_err()); + } + assert!(ProjectSettings::parse( + text.replace("adapter='cargo'", "adapter='unknown'") + .as_bytes() + ) + .is_err()); + } +} diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs new file mode 100644 index 0000000..5baa2e1 --- /dev/null +++ b/crates/daemon/src/lib.rs @@ -0,0 +1,4 @@ +//! Service ownership and configuration. No invented host evidence or hidden fallback. +pub mod config; +pub mod paths; +pub mod server; diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs new file mode 100644 index 0000000..168529c --- /dev/null +++ b/crates/daemon/src/main.rs @@ -0,0 +1,86 @@ +use devguard_contract::{Error, ErrorCode, Result}; +use devguard_core::AuthorityStorage; +use devguard_daemon::{ + config::{self, HostConfig}, + paths::AuthorityPaths, + server::Server, +}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +static STOP: AtomicBool = AtomicBool::new(false); +extern "C" fn stop_signal(_: libc::c_int) { + STOP.store(true, Ordering::Relaxed); +} + +fn run() -> Result<()> { + let args: Vec<_> = std::env::args().skip(1).collect(); + if args == ["--help"] || args == ["help"] { + println!("devguardd paths | init | check | serve\nNormal authority paths come from the OS account. No path or test-budget override is accepted.\nRuntime execution is unavailable until native host evidence and launch/reconciliation are implemented."); + return Ok(()); + } + if args.len() != 1 || !matches!(args[0].as_str(), "paths" | "init" | "check" | "serve") { + return Err(Error::new( + ErrorCode::InvalidRequest, + "expected paths, init, check or serve; no alternate authority arguments are supported", + )); + } + let paths = AuthorityPaths::current_user()?; + match args[0].as_str() { + "paths" => println!( + "{}", + serde_json::to_string(&paths) + .map_err(|_| Error::new(ErrorCode::InvalidRequest, "path encoding failed"))? + ), + "init" => { + config::initialize(&paths)?; + println!("Explicit bootstrap complete; runtime admission remains unavailable."); + } + "check" => { + paths.validate_existing()?; + let config = HostConfig::load(&paths)?; + let _storage = AuthorityStorage::open(&paths.journal())?; + println!( + "{}", + serde_json::json!({"configuration":config.fingerprint()?,"journal_valid":true,"runtime_ready":false,"reason":"native host evidence and launch are not installed"}) + ); + } + "serve" => { + let server = Server::open(&paths)?; + // SAFETY: handlers only store to a lock-free atomic; no allocation or I/O. + unsafe { + libc::signal(libc::SIGINT, stop_signal as *const () as libc::sighandler_t); + libc::signal( + libc::SIGTERM, + stop_signal as *const () as libc::sighandler_t, + ); + } + let stop = Arc::new(AtomicBool::new(false)); + let watched = stop.clone(); + let watcher = std::thread::spawn(move || { + while !watched.load(Ordering::Relaxed) { + if STOP.load(Ordering::Relaxed) { + watched.store(true, Ordering::Relaxed); + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + }); + let result = server.run(stop.clone()); + stop.store(true, Ordering::Relaxed); + let _ = watcher.join(); + result?; + } + _ => unreachable!(), + } + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} diff --git a/crates/daemon/src/paths.rs b/crates/daemon/src/paths.rs new file mode 100644 index 0000000..c9975dd --- /dev/null +++ b/crates/daemon/src/paths.rs @@ -0,0 +1,336 @@ +use devguard_contract::{Error, ErrorCode, Result}; +use serde::Serialize; +use std::ffi::CStr; +use std::fs::{self, DirBuilder, File, OpenOptions}; +use std::io::{Read, Write}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}; +use std::path::{Component, Path, PathBuf}; + +/// Fields cannot be supplied by a caller or deserialized from project configuration. +#[derive(Debug, Clone, Serialize)] +pub struct AuthorityPaths { + uid: u32, + home: PathBuf, + config_directory: PathBuf, + root: PathBuf, + runtime: PathBuf, + cache: PathBuf, +} + +impl AuthorityPaths { + pub fn current_user() -> Result { + if !cfg!(target_os = "macos") { + return Err(Error::new(ErrorCode::ResourcePolicyUnsupported, "normal authority currently requires macOS; Linux CI covers portable contracts only")); + } + // SAFETY: these process credential observations do not dereference pointers. + let uid = unsafe { libc::getuid() }; + if uid == 0 || uid != unsafe { libc::geteuid() } { + return Err(Error::new( + ErrorCode::Unauthorized, + "run as the current unprivileged user", + )); + } + // NSS account data, not HOME/XDG/socket overrides, determines normal ownership. + let mut buffer = vec![0u8; 64 * 1024]; + // SAFETY: passwd is an output-only C struct initialized by getpwuid_r. + let mut account: libc::passwd = unsafe { std::mem::zeroed() }; + let mut found = std::ptr::null_mut(); + // SAFETY: all output pointers reference live, appropriately sized buffers. + let result = unsafe { + libc::getpwuid_r( + uid, + &mut account, + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut found, + ) + }; + if result != 0 || found.is_null() || account.pw_dir.is_null() { + return Err(Error::new( + ErrorCode::ResourceControlUnavailable, + "cannot observe account home", + )); + } + // SAFETY: successful getpwuid_r provides a terminated string in buffer. + let home = PathBuf::from(std::ffi::OsStr::from_bytes( + unsafe { CStr::from_ptr(account.pw_dir) }.to_bytes(), + )); + if !home.is_absolute() { + return Err(invalid_path()); + } + Ok(Self::for_account( + uid, + home, + PathBuf::from(format!("/private/tmp/devguard-{uid}")), + )) + } + + fn for_account(uid: u32, home: PathBuf, runtime: PathBuf) -> Self { + Self { + uid, + config_directory: home.join(".config/devguard"), + root: home.join("Library/Application Support/DevGuard"), + cache: home.join("Library/Caches/DevGuard"), + home, + runtime, + } + } + + pub fn uid(&self) -> u32 { + self.uid + } + pub fn config(&self) -> PathBuf { + self.config_directory.join("host.toml") + } + pub fn root(&self) -> &Path { + &self.root + } + pub fn runtime(&self) -> &Path { + &self.runtime + } + pub fn state(&self) -> PathBuf { + self.root.join("state") + } + pub fn journal(&self) -> PathBuf { + self.state().join("authority.sqlite") + } + pub fn lock(&self) -> PathBuf { + self.state().join("authority.lock") + } + pub fn socket(&self) -> PathBuf { + self.runtime.join("authority.sock") + } + pub fn credentials(&self) -> PathBuf { + self.root.join("credentials") + } + pub fn cli_credential(&self) -> PathBuf { + self.credentials().join("dev-cli.secret") + } + pub fn admin_credential(&self) -> PathBuf { + self.credentials().join("admin.secret") + } + + /// Only explicit initialization may create persistent state/config directories. + pub fn prepare_bootstrap(&self) -> Result<()> { + for path in [ + &self.config_directory, + &self.root, + &self.state(), + &self.credentials(), + ] { + secure_directory(path, self.uid, true)?; + } + Ok(()) + } + + pub fn validate_existing(&self) -> Result<()> { + for path in [ + &self.config_directory, + &self.root, + &self.state(), + &self.credentials(), + ] { + secure_directory(path, self.uid, false)?; + } + validate_private_file(&self.journal(), self.uid)?; + validate_private_file(&self.lock(), self.uid)?; + Ok(()) + } + + pub fn prepare_runtime(&self) -> Result<()> { + secure_directory(&self.runtime, self.uid, true) + } + + #[cfg(test)] + pub(crate) fn fixture(base: &Path) -> Self { + // Test-only fixture paths are not a production daemon argument or budget mode. + Self::for_account( + unsafe { libc::getuid() }, + base.join("home"), + base.join("run"), + ) + } +} + +fn invalid_path() -> Error { + Error::new( + ErrorCode::Unauthorized, + "authority path has an unsafe type, owner or permission", + ) +} + +/// Walk each ancestor without following symlinks. Shared sticky tmp is permitted +/// only as an ancestor; every DevGuard leaf must be owned by this UID and 0700. +pub fn secure_directory(path: &Path, uid: u32, create: bool) -> Result<()> { + if !path.is_absolute() { + return Err(invalid_path()); + } + let mut current = PathBuf::new(); + for component in path.components() { + match component { + Component::RootDir => current.push("/"), + Component::Normal(name) => current.push(name), + _ => return Err(invalid_path()), + } + match fs::symlink_metadata(¤t) { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound && create => { + match DirBuilder::new().mode(0o700).create(¤t) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => return Err(invalid_path()), + } + } + Err(_) => return Err(invalid_path()), + } + let meta = fs::symlink_metadata(¤t).map_err(|_| invalid_path())?; + let shared_tmp = matches!(current.to_str(), Some("/private/tmp" | "/tmp")) + && meta.uid() == 0 + && meta.mode() & 0o1000 != 0; + if !meta.is_dir() + || meta.file_type().is_symlink() + || (meta.uid() != uid && meta.uid() != 0) + || (!shared_tmp && meta.mode() & 0o022 != 0) + { + return Err(invalid_path()); + } + if current == path && (meta.uid() != uid || meta.mode() & 0o077 != 0) { + return Err(invalid_path()); + } + } + Ok(()) +} + +pub fn validate_private_file(path: &Path, uid: u32) -> Result<()> { + let meta = fs::symlink_metadata(path).map_err(|_| invalid_path())?; + if !meta.is_file() + || meta.file_type().is_symlink() + || meta.uid() != uid + || meta.mode() & 0o077 != 0 + || meta.nlink() != 1 + { + return Err(invalid_path()); + } + Ok(()) +} + +pub fn read_private(path: &Path, uid: u32, limit: usize) -> Result> { + validate_private_file(path, uid)?; + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path) + .map_err(|_| invalid_path())?; + let meta = file.metadata().map_err(|_| invalid_path())?; + let named = fs::symlink_metadata(path).map_err(|_| invalid_path())?; + if (meta.dev(), meta.ino()) != (named.dev(), named.ino()) + || meta.uid() != uid + || meta.mode() & 0o077 != 0 + || !meta.is_file() + || meta.nlink() != 1 + { + return Err(invalid_path()); + } + let mut bytes = Vec::new(); + file.take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| invalid_path())?; + if bytes.len() > limit { + return Err(Error::new( + ErrorCode::InvalidRequest, + "private configuration exceeds its byte limit", + )); + } + Ok(bytes) +} + +pub fn write_new_private(path: &Path, bytes: &[u8]) -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path) + .map_err(|_| { + Error::new( + ErrorCode::InvalidRequest, + "bootstrap requires absent private files", + ) + })?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|_| { + Error::new( + ErrorCode::JournalInvalid, + "bootstrap write failed; preserve partial state for explicit repair", + ) + })?; + File::open(path.parent().ok_or_else(invalid_path)?) + .and_then(|f| f.sync_all()) + .map_err(|_| Error::new(ErrorCode::JournalInvalid, "bootstrap directory sync failed")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::{symlink, PermissionsExt}; + + pub(crate) fn temporary() -> tempfile::TempDir { + tempfile::Builder::new() + .prefix("dg-path-") + .tempdir_in(if cfg!(target_os = "macos") { + "/private/tmp" + } else { + "/tmp" + }) + .unwrap() + } + + #[test] + fn authority_paths_create_only_explicit_private_roots() { + let dir = temporary(); + let paths = AuthorityPaths::fixture(dir.path()); + assert!(paths.validate_existing().is_err()); + assert!(!paths.root().exists()); + paths.prepare_bootstrap().unwrap(); + paths.prepare_runtime().unwrap(); + assert_eq!(fs::metadata(paths.root()).unwrap().mode() & 0o777, 0o700); + assert!(!paths.journal().exists()); + assert_ne!(paths.state(), paths.runtime()); + } + + #[test] + fn authority_paths_reject_aliases_and_do_not_fix_user_permissions() { + let dir = temporary(); + let paths = AuthorityPaths::fixture(dir.path()); + paths.prepare_bootstrap().unwrap(); + let alias = dir.path().join("alias"); + symlink(paths.root(), &alias).unwrap(); + assert!(secure_directory(&alias, paths.uid(), false).is_err()); + fs::set_permissions(paths.root(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(paths.prepare_bootstrap().is_err()); + assert_eq!(fs::metadata(paths.root()).unwrap().mode() & 0o777, 0o755); + assert!(secure_directory(&paths.root().join("../other"), paths.uid(), true).is_err()); + } + + #[test] + fn authority_private_files_reject_links_oversize_and_shared_access() { + let dir = temporary(); + let path = dir.path().join("secret"); + let uid = unsafe { libc::getuid() }; + write_new_private(&path, b"private").unwrap(); + assert_eq!(read_private(&path, uid, 7).unwrap(), b"private"); + assert!(read_private(&path, uid, 6).is_err()); + assert!(write_new_private(&path, b"replacement").is_err()); + let link = dir.path().join("link"); + symlink(&path, &link).unwrap(); + assert!(read_private(&link, uid, 10).is_err()); + fs::hard_link(&path, dir.path().join("hard")).unwrap(); + assert!(read_private(&path, uid, 10).is_err()); + fs::remove_file(dir.path().join("hard")).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(read_private(&path, uid, 10).is_err()); + assert!(read_private(&path, uid.wrapping_add(1), 10).is_err()); + } +} diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs new file mode 100644 index 0000000..11a3542 --- /dev/null +++ b/crates/daemon/src/server.rs @@ -0,0 +1,596 @@ +use crate::{config::HostConfig, paths::AuthorityPaths}; +use devguard_client::{connect::connect_timeout, framing, peer, protocol::*}; +use devguard_contract::{validate_id, Error, ErrorCode, Result, PROTOCOL_VERSION}; +use devguard_core::{AuthorityStorage, ConsumerRole}; +use std::collections::BTreeSet; +use std::fs; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::thread::JoinHandle; +use std::time::Duration; + +pub struct Server { + listener: UnixListener, + socket: PathBuf, + socket_identity: (u64, u64), + config: Arc, + uid: u32, + status: ServiceStatus, + _storage: AuthorityStorage, +} + +fn unavailable(message: &'static str) -> Error { + Error::new(ErrorCode::ResourceControlUnavailable, message) +} +fn unauthorized() -> Error { + Error::new( + ErrorCode::Unauthorized, + "caller credential or role is not authorized", + ) +} + +impl Server { + pub fn open(paths: &AuthorityPaths) -> Result { + paths.validate_existing()?; + let config = HostConfig::load(paths)?; + let storage = AuthorityStorage::open(&paths.journal())?; + paths.prepare_runtime()?; + let socket = paths.socket(); + match fs::symlink_metadata(&socket) { + Ok(meta) => { + if !meta.file_type().is_socket() + || meta.uid() != paths.uid() + || meta.mode() & 0o077 != 0 + { + return Err(unauthorized()); + } + // Never unlink a live/busy/unobservable endpoint. An exclusive + // authority lock plus positive connection refusal permits stale cleanup. + match connect_timeout(&socket, Duration::from_millis(FRAME_DEADLINE_MS)) { + Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => {} + _ => { + return Err(unavailable( + "normal endpoint already exists or cannot be reconciled", + )) + } + } + let current = fs::symlink_metadata(&socket) + .map_err(|_| unavailable("endpoint changed during reconciliation"))?; + if (current.dev(), current.ino()) != (meta.dev(), meta.ino()) { + return Err(unavailable("endpoint identity changed")); + } + fs::remove_file(&socket) + .map_err(|_| unavailable("cannot remove confirmed stale endpoint"))?; + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(unavailable("cannot inspect normal endpoint")), + } + let listener = + UnixListener::bind(&socket).map_err(|_| unavailable("cannot bind normal endpoint"))?; + fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)) + .map_err(|_| unavailable("cannot protect normal endpoint"))?; + // SAFETY: listener owns the bound stream socket; bound backlog and workers + // independently, including unauthenticated clients and slow readers. + if unsafe { libc::listen(listener.as_raw_fd(), MAX_SESSIONS as i32) } != 0 { + return Err(unavailable("cannot bound listener backlog")); + } + listener + .set_nonblocking(true) + .map_err(|_| unavailable("cannot configure listener"))?; + let meta = fs::symlink_metadata(&socket) + .map_err(|_| unavailable("cannot observe bound endpoint"))?; + let status = ServiceStatus { + storage_validated: true, + registration_ready: false, + execution_ready: false, + reason: + "native process identity, host probes and launch/reconciliation are not installed" + .into(), + configuration_fingerprint: config.fingerprint()?, + }; + Ok(Self { + listener, + socket, + socket_identity: (meta.dev(), meta.ino()), + config: Arc::new(config), + uid: paths.uid(), + status, + _storage: storage, + }) + } + + pub fn run(self, stop: Arc) -> Result<()> { + let mut workers: Vec> = Vec::new(); + let mut result = Ok(()); + while !stop.load(Ordering::Relaxed) { + let mut index = 0; + while index < workers.len() { + if workers[index].is_finished() { + let _ = workers.swap_remove(index).join(); + } else { + index += 1; + } + } + match self.listener.accept() { + Ok((stream, _)) => { + if workers.len() >= MAX_SESSIONS { + drop(stream); + continue; + } + let config = self.config.clone(); + let uid = self.uid; + let status = self.status.clone(); + let stop = stop.clone(); + if let Ok(worker) = std::thread::Builder::new() + .name("devguard-session".into()) + .spawn(move || { + let _ = session(stream, uid, &config, &status, &stop); + }) + { + workers.push(worker); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)) + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => { + result = Err(unavailable("listener failed")); + break; + } + } + } + stop.store(true, Ordering::Relaxed); + for worker in workers { + let _ = worker.join(); + } + result + } +} + +impl Drop for Server { + fn drop(&mut self) { + // Do not remove an unknown replacement at the original pathname. + if let Ok(meta) = fs::symlink_metadata(&self.socket) { + if meta.file_type().is_socket() && (meta.dev(), meta.ino()) == self.socket_identity { + let _ = fs::remove_file(&self.socket); + } + } + } +} + +fn digest_matches(left: &str, right: &str) -> bool { + left.len() == right.len() + && left + .bytes() + .zip(right.bytes()) + .fold(0u8, |difference, (a, b)| difference | (a ^ b)) + == 0 +} + +fn authenticate(config: &HostConfig, credential: CallerCredential) -> Result { + match credential { + CallerCredential::Consumer { + consumer_id, + generation, + secret, + } => { + validate_id(&consumer_id)?; + validate_id(&generation)?; + let consumer = config + .consumers + .get(&consumer_id) + .ok_or_else(unauthorized)?; + if generation != consumer.generation + || !digest_matches(&consumer.credential_sha256, &secret.digest()) + { + return Err(unauthorized()); + } + Ok(match consumer.role { + ConsumerRole::Workload => SessionRole::Workload, + ConsumerRole::ControlService => SessionRole::ControlService, + }) + } + CallerCredential::Administrator { secret } => { + if !digest_matches(&config.admin_credential_sha256, &secret.digest()) { + return Err(unauthorized()); + } + Ok(SessionRole::Administrator) + } + } +} + +fn session( + mut stream: UnixStream, + uid: u32, + config: &HostConfig, + status: &ServiceStatus, + stop: &AtomicBool, +) -> Result<()> { + // Framing uses poll and per-call nonblocking I/O with an absolute deadline; + // it is independent of Darwin's inherited listener O_NONBLOCK flag. + let caller = peer::observe(&stream)?; + if caller.uid != uid { + return Err(unauthorized()); + } + let timeout = Duration::from_millis(FRAME_DEADLINE_MS); + let mut greeted = false; + let mut role = None; + while !stop.load(Ordering::Relaxed) { + let frame: Frame = framing::read_frame(&mut stream, timeout)?; + if frame.version != WIRE_VERSION || frame.request_id == 0 { + let body = Response::Error( + Error::new( + ErrorCode::ResourcePolicyUnsupported, + "unsupported wire version or request identity", + ) + .into(), + ); + framing::write_frame( + &mut stream, + &Frame { + version: WIRE_VERSION, + request_id: frame.request_id, + body, + }, + timeout, + )?; + return Ok(()); + } + let response: Result = (|| match frame.body { + Request::Hello { compatibility } => { + if greeted { + return Err(Error::new( + ErrorCode::InvalidTransition, + "session already negotiated", + )); + } + let capabilities = BTreeSet::new(); + compatibility.check(PROTOCOL_VERSION, &capabilities)?; + greeted = true; + Ok(Response::Hello(Hello { + protocol: PROTOCOL_VERSION, + authority: PeerIdentity { + uid, + pid: std::process::id(), + }, + caller, + capabilities, + max_frame_bytes: MAX_FRAME_BYTES, + frame_deadline_ms: FRAME_DEADLINE_MS, + max_sessions: MAX_SESSIONS, + })) + } + Request::Authenticate { credential } => { + if !greeted { + return Err(unauthorized()); + } + if role.is_some() { + return Err(Error::new( + ErrorCode::InvalidTransition, + "session already authenticated", + )); + } + let authenticated = authenticate(config, credential)?; + role = Some(authenticated); + Ok(Response::Authenticated { + role: authenticated, + }) + } + Request::Status => { + if role.is_none() { + return Err(unauthorized()); + } + Ok(Response::Status(status.clone())) + } + Request::Register { instance_id } => { + validate_id(&instance_id)?; + match role { + Some(SessionRole::Workload | SessionRole::ControlService) => Err(unavailable( + "native registration is not ready; no principal or budget was issued", + )), + _ => Err(unauthorized()), + } + } + })(); + let body = response.unwrap_or_else(|error| Response::Error(error.into())); + framing::write_frame( + &mut stream, + &Frame { + version: WIRE_VERSION, + request_id: frame.request_id, + body, + }, + timeout, + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config; + use crate::paths::read_private; + use devguard_client::Client; + use devguard_contract::{Capability, Compatibility, Secret}; + + struct Fixture { + _directory: tempfile::TempDir, + paths: AuthorityPaths, + config: HostConfig, + stop: Arc, + worker: Option>>, + } + impl Fixture { + fn new() -> Self { + let directory = tempfile::Builder::new() + .prefix("dg-rpc-") + .tempdir_in(if cfg!(target_os = "macos") { + "/private/tmp" + } else { + "/tmp" + }) + .unwrap(); + let paths = AuthorityPaths::fixture(directory.path()); + let config = config::initialize(&paths).unwrap(); + let server = Server::open(&paths).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let signal = stop.clone(); + let worker = Some(std::thread::spawn(move || server.run(signal))); + Self { + _directory: directory, + paths, + config, + stop, + worker, + } + } + fn compatibility() -> Compatibility { + Compatibility { + minimum_protocol: 1, + maximum_protocol: 1, + required: BTreeSet::new(), + } + } + fn client(&self) -> Client { + Client::connect( + &self.paths.socket(), + self.paths.uid(), + Self::compatibility(), + ) + .unwrap() + } + fn credential(&self) -> CallerCredential { + let secret = Secret::new( + String::from_utf8( + read_private(&self.paths.cli_credential(), self.paths.uid(), 64).unwrap(), + ) + .unwrap(), + ) + .unwrap(); + CallerCredential::Consumer { + consumer_id: "dev-cli".into(), + generation: self.config.consumers["dev-cli"].generation.clone(), + secret, + } + } + } + impl Drop for Fixture { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(worker) = self.worker.take() { + worker.join().unwrap().unwrap(); + } + } + } + + #[test] + fn authenticated_peer_is_os_observed_but_registration_and_execution_stay_closed() { + let fixture = Fixture::new(); + let mut client = fixture.client(); + assert_eq!(client.hello.caller.pid, std::process::id()); + assert_eq!(client.hello.caller.uid, fixture.paths.uid()); + assert_eq!(client.status().unwrap_err().code, ErrorCode::Unauthorized); + assert_eq!( + client.authenticate(fixture.credential()).unwrap(), + SessionRole::Workload + ); + let status = client.status().unwrap(); + assert!(status.storage_validated); + assert!(!status.registration_ready && !status.execution_ready); + assert_eq!( + client.register("owner".into()).unwrap_err().code, + ErrorCode::ResourceControlUnavailable + ); + assert!(Server::open(&fixture.paths).is_err()); + } + + #[test] + fn roles_secrets_generations_and_required_capabilities_cannot_be_substituted() { + let fixture = Fixture::new(); + let mut client = fixture.client(); + let CallerCredential::Consumer { secret, .. } = fixture.credential() else { + unreachable!() + }; + assert_eq!( + client + .authenticate(CallerCredential::Administrator { + secret: secret.clone() + }) + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + assert_eq!( + client + .authenticate(CallerCredential::Consumer { + consumer_id: "dev-cli".into(), + generation: "wrong".into(), + secret + }) + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + let admin = Secret::new( + String::from_utf8( + read_private(&fixture.paths.admin_credential(), fixture.paths.uid(), 64).unwrap(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!( + client + .authenticate(CallerCredential::Administrator { secret: admin }) + .unwrap(), + SessionRole::Administrator + ); + assert_eq!( + client.register("owner".into()).unwrap_err().code, + ErrorCode::Unauthorized + ); + let required = Compatibility { + required: BTreeSet::from([Capability::DurableAdmission]), + ..Fixture::compatibility() + }; + assert!( + matches!(Client::connect(&fixture.paths.socket(),fixture.paths.uid(),required),Err(e) if e.code==ErrorCode::ResourcePolicyUnsupported) + ); + assert!( + matches!(Client::connect(&fixture.paths.socket(),fixture.paths.uid()+1,Fixture::compatibility()),Err(e) if e.code==ErrorCode::Unauthorized) + ); + } + + #[test] + fn concurrent_registration_requests_never_create_unobserved_principals() { + let fixture = Fixture::new(); + let threads: Vec<_> = (0..8) + .map(|n| { + let path = fixture.paths.socket(); + let uid = fixture.paths.uid(); + let credential = fixture.credential(); + std::thread::spawn(move || { + let mut client = Client::connect(&path, uid, Fixture::compatibility()).unwrap(); + client.authenticate(credential).unwrap(); + assert_eq!( + client.register(format!("owner-{n}")).unwrap_err().code, + ErrorCode::ResourceControlUnavailable + ); + }) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + } + + #[test] + fn wrong_wire_and_unknown_peer_fields_are_rejected_without_leaking_secrets() { + let fixture = Fixture::new(); + let mut stream = UnixStream::connect(fixture.paths.socket()).unwrap(); + let timeout = Duration::from_millis(250); + framing::write_frame( + &mut stream, + &Frame { + version: 99, + request_id: 1, + body: Request::Hello { + compatibility: Fixture::compatibility(), + }, + }, + timeout, + ) + .unwrap(); + let result: Frame = framing::read_frame(&mut stream, timeout).unwrap(); + assert!( + matches!(result.body,Response::Error(e) if e.code==ErrorCode::ResourcePolicyUnsupported) + ); + let raw = serde_json::json!({"method":"authenticate","params":{"credential":{"kind":"consumer","consumer_id":"dev-cli","generation":"g","secret":"a".repeat(64),"pid":1,"uid":0}}}); + assert!(serde_json::from_value::(raw).is_err()); + assert!(!format!("{:?}", fixture.credential()).contains( + &String::from_utf8( + read_private(&fixture.paths.cli_credential(), fixture.paths.uid(), 64).unwrap() + ) + .unwrap() + )); + } + + #[test] + fn endpoints_require_positive_stale_evidence_and_preserve_replacements() { + let mut fixture = Fixture::new(); + fixture.stop.store(true, Ordering::Relaxed); + fixture.worker.take().unwrap().join().unwrap().unwrap(); + let path = fixture.paths.socket(); + assert!(!path.exists()); + let live = UnixListener::bind(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + assert!( + matches!(Server::open(&fixture.paths), Err(e) if e.code == ErrorCode::ResourceControlUnavailable) + ); + assert!(path.exists()); + drop(live); + let server = Server::open(&fixture.paths).unwrap(); + assert!(fs::symlink_metadata(&path).unwrap().file_type().is_socket()); + fs::remove_file(&path).unwrap(); + fs::write(&path, b"replacement evidence").unwrap(); + drop(server); + assert_eq!(fs::read(&path).unwrap(), b"replacement evidence"); + assert!(Server::open(&fixture.paths).is_err()); + fs::remove_file(&path).unwrap(); + std::os::unix::fs::symlink(fixture.paths.journal(), &path).unwrap(); + assert!(Server::open(&fixture.paths).is_err()); + assert!(fs::symlink_metadata(&path) + .unwrap() + .file_type() + .is_symlink()); + } + + #[test] + fn partial_frame_pressure_expires_and_shutdown_remains_bounded() { + use std::io::Write; + use std::time::Instant; + let mut fixture = Fixture::new(); + let mut streams = Vec::new(); + for _ in 0..MAX_SESSIONS * 2 { + if let Ok(mut stream) = + connect_timeout(&fixture.paths.socket(), Duration::from_millis(250)) + { + let _ = stream.write_all(&[0]); + streams.push(stream); + } + } + // Every partial frame must expire, regardless of accepted/backlogged order. + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Ok(mut client) = Client::connect( + &fixture.paths.socket(), + fixture.paths.uid(), + Fixture::compatibility(), + ) { + if client.authenticate(fixture.credential()).is_ok() && client.status().is_ok() { + break; + } + } + assert!( + Instant::now() < deadline, + "service did not recover after bounded partial frames" + ); + std::thread::sleep(Duration::from_millis(20)); + } + let mut final_peer = + connect_timeout(&fixture.paths.socket(), Duration::from_millis(250)).unwrap(); + final_peer.write_all(&[0, 0]).unwrap(); + let began = Instant::now(); + fixture.stop.store(true, Ordering::Relaxed); + fixture.worker.take().unwrap().join().unwrap().unwrap(); + assert!(began.elapsed() < Duration::from_secs(2)); + assert!(!fixture.paths.socket().exists()); + } +} diff --git a/crates/daemon/tests/entrypoint.rs b/crates/daemon/tests/entrypoint.rs new file mode 100644 index 0000000..a076fbb --- /dev/null +++ b/crates/daemon/tests/entrypoint.rs @@ -0,0 +1,59 @@ +use std::process::Command; + +#[test] +fn alternate_authority_and_unparented_candidate_arguments_are_rejected() { + for arguments in [ + vec!["check", "--state", "/tmp/second-authority"], + vec!["check", "--socket", "/tmp/second.sock"], + vec!["init", "--test-capacity", "8000"], + vec!["candidate"], + ] { + let result = Command::new(env!("CARGO_BIN_EXE_devguardd")) + .args(arguments) + .output() + .unwrap(); + assert!(!result.status.success()); + assert!(String::from_utf8(result.stderr) + .unwrap() + .contains("no alternate authority arguments")); + } +} + +#[test] +fn bootstrap_help_does_not_claim_runtime_availability() { + let result = Command::new(env!("CARGO_BIN_EXE_devguardd")) + .arg("--help") + .output() + .unwrap(); + assert!(result.status.success()); + assert!(String::from_utf8(result.stdout) + .unwrap() + .contains("Runtime execution is unavailable")); +} + +#[cfg(target_os = "macos")] +#[test] +fn normal_paths_ignore_home_and_xdg_overrides() { + let run = |override_home: bool| { + let mut command = Command::new(env!("CARGO_BIN_EXE_devguardd")); + command.arg("paths"); + if override_home { + command + .env("HOME", "/tmp/alternate-home") + .env("XDG_STATE_HOME", "/tmp/alternate-state"); + } + let output = command.output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice::(&output.stdout).unwrap() + }; + let observed = run(false); + assert_eq!(observed, run(true)); + assert!(!observed["home"] + .as_str() + .unwrap() + .starts_with("/tmp/alternate")); +} diff --git a/docs/contracts.md b/docs/contracts.md index cf5a61b..2987752 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -1,14 +1,26 @@ -# DG-0 implementation contract +# Implemented authority contract -This document clarifies the approved design without claiming that its daemon, launcher, CodeSpace integration or OS-specific milestones are implemented. +This document describes the implemented DG-0 authority and C01/C02 service, storage and transport behavior. PR and post-merge main delivery evidence is tracked separately from implementation. [Operations](operations.md) lists actual command availability; native registration/launch, OS policies and CodeSpace integration remain unimplemented. [Korean translation](ko/contracts.md). ## Authority and transport boundaries -The core is a Rust library. `Authority::register` receives a `TrustedPeer` from a future transport's OS peer-credential check and verifies the configured UID, consumer credential, generation and exact process identity through `Backend`. Registration returns an opaque `Principal`; a workload cannot construct a control-service principal through the public API. A workload consumer cannot configure a control reservation. The administrative reconciliation and generation-retirement methods belong to a trusted daemon/operator path and must not be exposed as workload RPCs. +The core is a Rust library. `Authority::register` accepts a `TrustedPeer` and verifies the configured UID, consumer credential, generation and exact process identity through `Backend`. Registration returns an opaque `Principal`; a workload cannot construct a control-service principal through the public API. A workload consumer cannot configure a control reservation. The administrative reconciliation and generation-retirement methods belong to a trusted daemon/operator path and must not be exposed as workload RPCs. -DG-0 proves this library boundary with a fake peer and backend. It does not implement UDS authentication, credential-FD transfer, same-UID adversary isolation or a network client. DG-1 must supply these real boundaries before advertising a production service. +DG-0 proves the library registration boundary with a fake peer and backend. C02 supplies real local UDS authentication and a small client, but does not activate native registration. The server observes peer UID/PID through OS socket credentials; the client independently corroborates the authority UID/PID and its own identity in the handshake. Neither accepts caller-declared peer identity. C03 must still provide boot/start identity before the daemon may construct a complete trusted registration observation. Authentication alone issues no `Principal`, instance slot, lease or host budget. Workload/control-service registration requests currently return `ResourceControlUnavailable`; administrative credentials cannot perform registration. -The authority holds an exclusive no-follow lock in the journal's parent directory. All journals in that authority directory share the lock. DG-1 must use the canonical normal-service state directory and restrict alternate directories to a bounded parent-lease test mode; changing a socket or state argument must not create a second full-host authority. +The handshake negotiates contract compatibility and wire version 1, with no runtime capabilities advertised by C02. Consumer generation and credential digest determine workload/control-service roles; an independent administrative digest grants only the explicitly exposed administrative role. All consumer and administrative digests must differ. Caller credentials, future one-time helper permits and administrative operations are separate boundaries: a helper credential variant is not accepted as caller authentication. This remains a cooperative operating-account model, not isolation from a malicious same-UID process. + +The authority holds an exclusive no-follow lock in the journal's parent directory. All journals in that authority directory share the lock. The lock is opened with `O_NONBLOCK`, and its opened descriptor must identify a private, current-UID regular file with exactly one link, including during explicit initialization. A FIFO or linked file cannot stand in for the lock. C01 derives canonical normal-service paths from the OS account rather than caller HOME/XDG values and refuses state/socket overrides. Project configuration cannot carry authority credentials or capacity. Production candidate paths remain unavailable until C10 supplies a parent-lease boundary. + +`AuthorityStorage` exclusively opens and validates a journal without inventing a boot clock, recovering attempts or granting capabilities. `Authority::from_storage` activates it with an actual Backend/Clock and revalidates the accounting index inside the recovery transaction. `Authority::open` preserves that behavior through the same path. Explicit bootstrap remains separate from ordinary open; missing/corrupt/future-schema state is not repaired automatically. + +## Bounded local protocol and credential transport + +A frame has a four-byte length prefix and at most 64 KiB of JSON payload. Frames, message variants and nested wire types reject unknown fields; version/request identity and required capabilities are checked separately. Added fields are not automatically backward compatible. The server accepts at most 32 active session workers. Each frame read or write has an absolute 250 ms deadline, including idle waiting before the next frame; receiving another byte does not restart the deadline. An expired idle session is closed. These transport bounds do not establish the later end-to-end admission budget or C12 responsiveness qualification. + +Framing uses `poll`, descriptor `O_NONBLOCK` and per-call nonblocking socket I/O. Descriptor nonblocking mode also bounds large writes on Darwin, where a per-call flag alone is insufficient. It drains buffered final data on peer closure without changing socket timeout options, which can fail with `EINVAL` on Darwin after the peer has closed. A malformed, truncated, expired or unavailable response cannot imply execution or release. The client does not retry automatically, and never substitutes an unmanaged authority or execution. + +`CredentialHandoff` transfers one caller secret through a dedicated inherited descriptor, keeping the parent copy close-on-exec. `take_inherited`/`read_owned` consume and close the receiver descriptor on success or error, with bounded length and a 250 ms read deadline. Secret serialization is deliberate for the local authentication exchange; debugging and parser errors redact credentials. Subprocess tests observe that the FD is closed before a subsequent `exec` and the secret is absent from argv/environment/output. They validate transport hygiene, not C05 helper authorization, READY, payload startup or containment. ## Durable admission and launch @@ -42,7 +54,7 @@ Every resource carries its own level and method. Accounting is not an OS memory ## Boundaries deliberately left to later milestones -- DG-1: host probes, canonical service paths, real UDS credentials, daemon/client framing, launch helper, CLI, Cargo adaptation, bounded self-use, update/repair and measured macOS SLOs. +- Remaining DG-1: native boot/start identity and registration, host probes, resource policy application, launch helper, execution CLI, Cargo adaptation, bounded self-use, update/repair and measured macOS SLOs. - CS-RG: Runner slots and transport lanes, approval migration, pinned client, process status integration and regression qualification. - DG-LINUX: actual cgroup hierarchy, controllers, ancestor constraints and sandbox/proxy inclusion. - DG-CACHE / DG-ADAPTERS: registered cache reclamation and additional tool-specific controls. diff --git a/docs/ko/contracts.md b/docs/ko/contracts.md new file mode 100644 index 0000000..adc7481 --- /dev/null +++ b/docs/ko/contracts.md @@ -0,0 +1,62 @@ +# 구현된 authority 계약 + +이 문서는 구현된 DG-0 authority와 C01/C02 서비스·저장소·transport 동작을 설명한다. PR과 병합 후 main 전달 증거는 구현과 별도로 추적한다. 실제 제공 명령은 [운영 문서](operations.md)에 있다. Native 등록·launch, OS 정책 적용과 CodeSpace 결합은 아직 구현하지 않았다. [영문 정본](../contracts.md). + +## Authority와 transport 경계 + +Core는 Rust 라이브러리다. Authority::register는 TrustedPeer를 받고, 설정 UID·소비자 자격·generation·정확한 프로세스 정체성을 Backend로 검증한다. 성공하면 불투명 Principal을 반환하며 workload는 공개 API로 control-service Principal을 만들 수 없다. Workload 소비자는 제어 예약을 설정할 수 없다. 관리 대조와 generation 폐기는 신뢰하는 daemon·운영자 경로의 책임이며 workload RPC로 공개하면 안 된다. + +DG-0는 가짜 peer·backend로 라이브러리 등록 경계를 검증한다. C02는 실제 로컬 UDS 인증과 작은 client를 제공하지만 native 등록은 활성화하지 않는다. 서버는 OS socket 자격으로 peer UID/PID를 관측하고, client는 handshake의 authority UID/PID와 자기 정체성을 독립적으로 대조한다. 호출자가 선언한 peer 정체성을 신뢰하지 않는다. Daemon이 완전한 등록 관측을 구성하려면 C03의 boot/start 정체성이 추가로 필요하다. 인증만으로 Principal·instance 슬롯·lease·호스트 예산을 발급하지 않는다. 현재 workload/control-service 등록은 ResourceControlUnavailable을 반환하고 관리 자격으로는 등록할 수 없다. + +Handshake는 contract 호환성과 wire version 1을 확인하며 C02는 runtime capability를 광고하지 않는다. Consumer generation과 자격 digest가 workload/control-service 역할을 결정하며 별도 관리 digest는 명시적으로 공개한 관리 역할에만 사용한다. 모든 consumer·관리 digest는 서로 달라야 한다. Caller 자격, 후속 일회성 helper permit과 관리 작업은 별도 경계이며 helper 자격 variant를 caller 인증으로 받지 않는다. 이는 협조적인 운영 계정 모델이며 악의적인 동일 UID 프로세스를 격리하지 않는다. + +Authority는 journal 부모 디렉터리에 no-follow 배타 잠금을 보유한다. 같은 디렉터리의 모든 journal은 그 잠금을 공유한다. 잠금은 O_NONBLOCK으로 열고 열린 descriptor가 현재 UID 소유의 private 일반 파일이며 링크가 정확히 하나인지 명시 초기화 때도 확인한다. FIFO·링크된 파일은 잠금 대신 사용할 수 없다. C01은 caller HOME·XDG가 아니라 OS 계정으로 정상 경로를 결정하고 state·socket override를 거절한다. 프로젝트 설정에는 authority 자격이나 호스트 용량을 둘 수 없다. 운영 후보 경로는 C10의 부모 lease 경계가 제공될 때까지 사용할 수 없다. + +AuthorityStorage는 boot clock을 만들거나 attempt를 복구하거나 capability를 부여하지 않고 journal의 배타 열기·검사만 수행한다. Authority::from_storage는 실제 Backend·Clock으로 활성화하며 복구 transaction 안에서 회계 인덱스를 다시 검증한다. 기존 Authority::open도 같은 경로로 기존 복구 동작을 유지한다. 명시 bootstrap과 일반 open은 별개이며 누락·손상·미래 schema를 자동 수리하지 않는다. + +## 제한된 로컬 protocol과 자격 전달 + +Frame은 4-byte 길이와 최대 64 KiB JSON payload로 구성한다. Frame·message variant·중첩 wire type은 알 수 없는 필드를 거절하고 version·request identity·필수 capability는 따로 확인한다. 필드 추가를 자동 하위 호환으로 취급하지 않는다. 서버의 활성 session worker는 최대 32개다. 각 frame read/write의 절대 기한은 250 ms이며 다음 frame을 기다리는 idle 시간도 포함한다. Byte를 더 받아도 기한은 연장하지 않고 idle 기한을 넘긴 세션은 닫는다. 이 transport 경계가 후속 end-to-end admission 예산이나 C12 응답성 qualification을 입증하지는 않는다. + +Framing은 poll·descriptor O_NONBLOCK·호출별 nonblocking socket I/O를 사용한다. Darwin에서는 호출별 flag만으로 큰 write가 제한되지 않을 수 있으므로 descriptor nonblocking도 적용한다. Peer 종료 후 Darwin timeout 옵션 변경이 EINVAL로 실패할 수 있어 옵션을 바꾸지 않고 버퍼에 남은 마지막 데이터를 읽는다. 잘못되거나 잘린 응답, 만료·통신 장애에서 실행이나 회수를 추정하지 않는다. Client는 자동 재시도나 비관리 authority·실행 fallback을 하지 않는다. + +CredentialHandoff는 전용 상속 descriptor로 caller secret 하나를 전달하고 부모의 복사본에는 close-on-exec을 유지한다. take_inherited/read_owned는 제한된 길이와 250 ms 읽기 기한을 적용하며 성공·실패 모두 receiver descriptor를 소비하고 닫는다. Secret은 로컬 인증 교환을 위해 명시 직렬화하고 debug·파서 오류에서는 정제한다. Subprocess 시험은 후속 exec 전에 FD가 닫히고 argv·환경·출력에 secret이 없음을 관측한다. 이는 transport 위생 검증이며 C05 helper 권한·READY·사용자 프로그램 시작·격리의 qualification이 아니다. + +## 내구성 admission과 launch + +Journal schema는 1이다. 초기화는 명시적으로 새 파일만 만든다. 누락·손상·미지원 schema·불일치 journal은 fail-closed다. SQLite는 WAL, FULL synchronous와 immediate transaction을 사용한다. 시작 시 모든 저장 record와 회계 index를 대조한다. + +Attempt key는 `(consumer_id, consumer_generation, attempt_id)`다. 요청 fingerprint는 버전 있는 실행 digest와 자원 intent를 포함하고 transport ID·현재 policy revision을 제외한다. Replay는 최초 내구 예약·종결 결과를 반환하고 의미나 owner 변경은 충돌한다. 거절도 종결 attempt이므로 나중에 별도로 요청하는 admission은 새 attempt ID를 사용한다. + +begin_launch는 Prepared를 내구성 있게 소비하고 일회성 Secret permit을 반환한다. 반복 호출에는 새 spawn 권한이 없고 기존 attempt만 있다. Journal에는 permit digest만 저장한다. 첫 응답이 유실되면 대조해야 하며 transition replay로 helper를 다시 만들 수 없다. + +bind_scope는 attempt·owner·정확한 프로세스 정체성·scope·전체 적용 plan을 연결한 신선한 Backend 증거를 요구한다. authorize_run은 연결된 helper 정체성과 permit을 검증하며 최초 성공 응답만 may_exec=true다. 이 응답 유실은 불확실·차단된 실행이지 재생성 허용이 아니다. RunAuthorized는 사용자 executable 성공의 증거가 아니다. + +Prepared 취소는 종결하고 예약을 반환한다. Commit 후 취소는 Draining으로 전환하고 늦은 bind·authorize를 막으며 예약을 유지한다. Prepared만 5초 후 만료된다. 재시작은 Prepared의 원래 boot-relative 기한을 유지하고 commit 후 미종결 attempt와 등록 instance를 Suspect로 전환하여 대조한다. + +## 회수 증거 + +무조건 release(lease_id) API는 없다. Bound 실행은 정확한 scope와 신선한 root 종료·reap, scope 비움, 알려진 구성원 생존 없음, 완전한 추적과 알려진 이탈 없음이 필요하다. 이전 추적 상실은 평범한 빈 group 관측만으로 사라지지 않는다. 명시 대조 증거나 검증된 호스트 reboot 종료가 필요하다. + +Unbound committed launch는 PID 누락이나 owner 사망만으로 회수하지 않는다. Backend가 helper 미생성과 모든 pending spawn 부재를 적극적으로 입증해야 한다. Reboot로 이전 실행 종료를 대조할 수도 있다. release_reason은 scope 종료·helper 미생성·이전 boot 종료를 구분한다. known_not_started는 prelaunch 종결 거절과 Released/NoHelperCreated에서만 참이다. 자원 반환 자체는 재시도 증거가 아니다. + +일반 TTL sweep은 tombstone을 삭제하지 않는다. 운영자 generation 폐기에는 모든 instance retired와 charge 없음이 필요하다. 종결 attempt 압축 전에 영구 retired generation을 기록하여 과거 key를 계속 거절한다. + +정적 제어 예약은 단절·offline을 포함한 모든 설정 슬롯에서 차감한다. Instance retire는 슬롯 재사용만 허용하고 정적 예약을 workload로 반환하지 않는다. 프로세스 정체성에는 boot ID·PID·start ticks가 있어 PID 재사용을 검출한다. + +Journal은 instance의 등록 정책을 기록한다. Active/suspect instance가 남은 동안 소비자 삭제나 generation·role·UID·instance 상한·예약 변경은 시작 시 거절한다. 기존 설정에서 먼저 대조·retire한다. 슬롯 수는 generation을 가로질러 합산하므로 설정 재시작으로 같은 정적 예약에 두 번째 서비스를 배정하지 못한다. + +## 압력과 capability + +최초 유효 sample 전에는 closed다. 최초 정상 sample은 준비를 열지만 압력·관측 실패 후에는 설계의 30초 단계 복귀를 따른다. 같은 boot-relative monotonic clock을 사용하고 replay·미래 timestamp를 거절하며 6초가 지나면 stale이다. 목표 감소로 live lease 금액을 줄이지 않는다. + +자원마다 level·method가 있다. Accounting은 OS 메모리 상한이 아니고 QoS는 메모리·task 제한이 아니며 kernel 제어에는 contained cgroup이 필요하다. Admission 전에 plan을 검증하고 AppliedResources와 구분한다. 호환성은 protocol과 모든 필수 capability를 함께 확인하며 fallback authority를 시작하지 않는다. + +## 후속 마일스톤의 책임 + +- 남은 DG-1: native boot/start 정체성과 등록, host probe, 자원 정책 적용, helper, 실행 CLI, Cargo, bounded 자기 적용, update·repair, 측정한 macOS SLO. +- CS-RG: Runner 슬롯·전송 lane, 승인 migration, client pin, 상태 결합과 회귀 qualification. +- DG-LINUX: 실제 cgroup 계층·controller·ancestor와 sandbox·proxy 포함. +- DG-CACHE·DG-ADAPTERS: 등록 cache 회수와 추가 도구 제어. + +Journal·라이브러리는 사용자 프로세스 handle·출력·CodeSpace workspace lease·approval row를 소유하지 않는다. 자원 예약 수명으로 이들의 수명을 추정하지 않는다. diff --git a/docs/ko/operations.md b/docs/ko/operations.md new file mode 100644 index 0000000..e6ca8ff --- /dev/null +++ b/docs/ko/operations.md @@ -0,0 +1,90 @@ +# DevGuard 서비스 경계 운영 + +[English](../operations.md) | [한국어](operations.md) + +C01은 명시 bootstrap·고정 저장소를, C02는 인증된 로컬 transport를 제공한다. PR과 병합 후 main 전달 증거는 구현과 별도로 추적한다. devguardd serve는 foreground 서비스를 실행하지만 native 등록·Principal·자원 lease·실행은 P2/P3의 실제 호스트 정체성·probe·launch·대조가 제공될 때까지 닫혀 있다. 정상 authority는 현재 macOS만 지원하며 Linux CI는 이식 가능한 계약과 제한된 fixture를 검사한다. DG-LINUX 제어 자격을 부여하지 않는다. + +## 제공 명령 + +Bootstrap에서는 Rust 1.95.0과 단일 Cargo job을 사용한다. + +```sh +CARGO_BUILD_JOBS=1 cargo build --locked -p devguard-daemon +target/debug/devguardd paths +target/debug/devguardd init +target/debug/devguardd check +target/debug/devguardd serve +``` + +`paths`는 운영 계정의 경로를 조회한다. `init`은 최초 bootstrap에만 쓰며 새 journal, 운영자 설정, 분리된 CLI·관리 자격을 생성한다. 기존 상태는 거절하고 덮어쓰지 않는다. `check`는 저장소 배타 소유권과 설정·journal을 검사하며 `runtime_ready: false`를 보고한다. 가짜 boot clock을 만들거나 복구·적용 완료를 주장하지 않는다. 다른 소유자가 잠금을 보유하면 두 번째 authority를 얻을 수 없다. + +`serve`는 기존 journal을 검증하여 열고 배타 소유권을 얻은 뒤 정상 private UDS endpoint를 연다. Ctrl-C나 SIGTERM으로 foreground 프로세스를 중지한다. 종료는 세션과 자기 socket inode만 정리하며 journal·lock·자격은 보존한다. 남은 socket은 배타 authority lock을 얻고 연결 시도가 명시적인 connection refused를 반환하며 inode가 그대로일 때만 제거한다. 살아 있거나 busy이거나 관측할 수 없는 endpoint는 지우지 않는다. + +인증된 status는 storage_validated: true, registration_ready: false, execution_ready: false, 이유와 설정 fingerprint를 보고한다. 일반 실행·설치·LaunchAgent·repair는 후속 작업이다. 설정이나 handshake 성공만으로 명령이 관리되지는 않으며 bootstrap 빌드는 자기 적용 증거가 아니다. 영속 서비스는 제거 가능한 target 경로를 사용하지 않는다. 보호된 설치는 C09의 범위다. + +## 정상 소유권과 경로 + +정상 경로는 OS 계정 데이터베이스에서 결정한다. caller의 HOME·XDG·socket·state override와 무관하다. root·setuid 실행은 거절한다. 운영용 대체 경로나 부모 없는 시험 예산 인자는 없다. 후보 경로에는 후속 C10 부모 예산 계약이 필요하며, 격리 fixture 경로는 시험 빌드 안에서만 생성한다. + +| 용도 | macOS 경로 | +| --- | --- | +| 운영자 설정 | `~/.config/devguard/host.toml` | +| 영속 원장 | `~/Library/Application Support/DevGuard/state/authority.sqlite` | +| 영속 배타 잠금 | `~/Library/Application Support/DevGuard/state/authority.lock` | +| 등록·관리 자격 | `~/Library/Application Support/DevGuard/credentials/` 아래 별도 파일 | +| runtime endpoint | `/private/tmp/devguard-/authority.sock` | +| 후속 cache | `~/Library/Caches/DevGuard/` | + +DevGuard 디렉터리는 0700, 파일은 0600이며 소유자·종류·symlink를 검사한다. 부모 경로 이동, 안전하지 않은 상위 디렉터리, 링크된 private 파일과 공유 권한은 거절하고 사용자 경로를 자동 chmod하지 않는다. 공유 sticky tmp는 private runtime 디렉터리의 상위 경로로만 허용한다. journal·lock은 tmp 밖에 둔다. + +잠금은 O_NONBLOCK으로 열고 열린 descriptor의 metadata로 현재 UID 소유·private 일반 파일·링크 하나를 명시 초기화 때도 확인한다. FIFO·hard-link fixture는 blocking이나 authority 소유권 획득 없이 실패해야 한다. + +일반 시작에는 기존 영속 디렉터리·journal·lock이 필요하다. 누락·손상·미지원 상태를 자동 초기화하지 않는다. 명시 bootstrap의 부분 실패는 진단과 명시 repair를 위해 보존한다. init 재실행은 repair가 아니다. 시작을 통과시키려고 활성 lock·journal·자격·복구 artifact를 제거하지 않는다. + +## 설정 권한 + +운영자 파일은 크기가 제한된 UTF-8 TOML schema 1이며 알 수 없는 필드를 거절한다. 정책 revision, interactive profile, 소비자의 generation·자격 digest·역할·최대 인스턴스·정적 제어 예약과 프로젝트 root를 정의한다. 평문 자격은 별도 private 파일에만 둔다. 모든 consumer·관리 자격 digest는 서로 달라야 하며 workload 역할은 제어 예약을 부여할 수 없다. 파서 오류에 설정 원문이나 자격을 출력하지 않는다. + +최초 `dev-cli`는 인스턴스 8개를 허용하고 인스턴스마다 제어 예약을 추가하지 않는다. 승인된 CLI 관제 풀은 실제 회계가 제공될 때 중앙에서 한 번 계산한다. task 설정의 초기값은 용량 256, 호스트 여유 64, 시스템 예약 48의 **회계 추정치**다. system_tasks는 제한된 session worker 32개와 서비스·관제 여유 task 16개를 포함하여 최소 48이어야 한다. 운영자가 설정하는 상한이며 macOS kernel 제한이나 측정된 충분성이 아니다. C12에서 선택한 값을 기록·검증해야 한다. CPU·메모리의 여유분과 제어 기본값은 승인 설계를 유지하고 C03에서 실제 용량을 사용한다. 추가 여유분은 용량을 줄일 수만 있다. + +이전 system_tasks = 16 기본값을 사용하는 C01 설정은 C02에서 거절한다. 설정 schema는 1을 유지하며 이는 더 엄격한 의미 검증이지 자동 호환성이나 migration이 아니다. C02 시작 전에 운영자는 전체 task 용량·여유분·모든 예약을 검토하고 해당 용량 안에서 system_tasks >= 48을 명시 선택해야 한다. 기존 journal·자격을 보존한다. 검증을 통과시키려고 init을 다시 실행하거나 호스트 용량을 자동 확대하거나 live 예약을 줄이면 안 된다. + +프로젝트 `.devguard.toml`은 schema, project ID, profile, adapter와 더 낮은 Budget 상한(cpu_milli, memory_bytes, tasks)만 가진다. 자격·역할·별도 authority·소비자 사칭·호스트 용량은 지정할 수 없다. 프로젝트 상한은 운영자 상한에 들어가야 하며 미지원 필드·adapter·버전은 거절한다. 프로젝트별 설정은 authority core에 들어가지 않는다. + +```toml +schema = 1 +project_id = "devguard-dev" +profile = "interactive" +adapter = "cargo" + +[limits] +cpu_milli = 1000 +memory_bytes = 2147483648 +tasks = 32 +``` + +이 예시는 실행이 이미 lease를 소비한다는 증거가 아니다. 후속 CLI 도입 전에 실제 절대 프로젝트 root를 운영자 설정에 등록한다. 프로젝트나 자격은 추가 호스트 예산을 만들지 않는다. + +## Client 인증과 transport 제한 + +Client library는 정상 endpoint에 연결하고 OS가 관측한 authority UID/PID를 확인하며 wire version 1과 handshake의 자기 UID/PID를 대조한다. Consumer ID·generation·secret으로 설정된 workload/control-service 역할을 인증하고 별도 secret으로 관리 역할을 인증한다. UID 일치만으로 역할을 얻지 않는다. Helper permit은 caller 자격으로 사용할 수 없고 동일 UID의 악의적인 프로세스를 격리하지 않는다. + +Wire는 4-byte 길이, JSON payload 최대 64 KiB, 활성 세션 최대 32개와 frame read/write마다 절대 250 ms 기한을 사용한다. 다음 frame을 기다리는 idle 시간도 포함하므로 idle 연결은 만료된다. 나중에 별도 작업을 명시적으로 시작할 때 새 인증 세션을 사용한다. Client가 자동으로 재접속하거나 재시도하지는 않는다. Poll·nonblocking descriptor I/O는 부분 frame·느린 reader·마지막 응답 버퍼를 처리하고 peer 종료 뒤 Darwin timeout 옵션을 바꾸지 않는다. 응답 유실로 실행·미실행·자원 회수를 입증했다고 판단하지 않는다. + +Private-FD API는 시작 metadata에 descriptor 식별자만 전달한다. Receiver는 이후 exec 전에 자격 FD를 소비하고 닫으며 실제 subprocess로 이 경계를 시험한다. Secret을 argv·환경·debug·payload 상속 descriptor에 두면 안 된다. 이 시험은 C05 helper 권한이나 사용자 프로그램 시작의 증거가 아니다. OS UID/PID 관측은 현재 제공하지만 native boot/start 정체성과 instance 등록은 C03이 필요하다. C02의 인증 caller는 Principal·lease·실행 권한을 받을 수 없다. + +SDK 조회 예제는 CARGO_BUILD_JOBS=1 cargo build --locked -p devguard-client --example inspect로 빌드한다. 인터페이스는 inspect SOCKET UID CONSUMER GENERATION CREDENTIAL_FD다. 부모가 전용 상속 FD로 secret byte를 제공해야 하며 인자는 FD 번호와 비밀이 아닌 연결 metadata만 전달한다. 관측 peer와 인증된 status를 출력하는 예제이며 후속 일반 실행 CLI는 아니다. + +## 호환성·검사·복귀 + +Core의 AuthorityStorage는 기존 배타 잠금을 보유하고 schema 1 journal을 검사하되 attempt 상태를 바꾸지 않는다. 실제 Backend·Clock으로 활성화할 때 boot 기반 복구와 같은 transaction 안에서 회계를 다시 검증한다. 기존 Authority::open의 복구 동작과 DG-0 시험을 유지하며 C01/C02는 journal·기존 contract 직렬화 형식을 바꾸지 않는다. 새 로컬 protocol은 미지 필드·버전·필수 capability 미지원을 엄격히 거절하며 필드 추가도 명시적 호환성 시험이 필요하다. + +```sh +python3 scripts/qualify.py dg1-authority --offline +python3 scripts/qualify.py dg1-auth --offline +python3 scripts/validate.py --offline +``` + +기능 suite는 0개 실행을 실패로 처리하고 source fingerprint·toolchain·bootstrap 모드·로그를 남긴다. dg1-authority는 배타 시작, 경로 별칭·권한, 누락·손상·미래 journal, 활성화 사이의 회계 손상, 엄격한 설정 버전, 프로젝트 권한 상승과 동시 bootstrap을 검사한다. dg1-auth는 실제 peer 관측·인증 역할·엄격한 frame·제한된 통신·private FD 위생과 등록이 닫힌 상태의 동시 요청을 검사한다. Native 등록·launch·Linux 강제·자기 적용·foreground SLO는 not_run이다. 전체 검증은 기존 44개 시험과 workspace crate 4개의 명시적 전체 의존 그래프를 검사한다. Core·contract는 daemon 설정과 독립적이며 client는 core에 의존하지 않는다. + +복귀할 때 작업 소유 foreground 프로세스를 중지하고 보존한 설정과 schema 1 journal에 호환되는 source/artifact를 선택하며 영속 상태·자격을 유지한다. C01/C02를 통해 시작한 workload는 없다. 후속 live lease의 복귀에는 실제 대조가 필요하므로 이 초기 빈 상태 가정을 재사용할 수 없다. diff --git a/docs/ko/planning/README.md b/docs/ko/planning/README.md index 48e3607..6682a7a 100644 --- a/docs/ko/planning/README.md +++ b/docs/ko/planning/README.md @@ -2,7 +2,7 @@ 문서 기준일: 2026-09-22. 기준 저장소: `/Volumes/DevData/Projects/IdeaProjects/DevGuard`. 이 문서 집합은 승인 설계를 구현 가능한 작업·도입 gate·시험·PR 경계로 구체화한다. 영문 문서가 편집 정본이며 이 문서는 검토된 한국어 번역이다. CodeSpace 소비 안내도 영어·한국어를 함께 유지한다. -**현재 적용 가능 범위는 DG-0 계약 검토와 adapter 준비다.** 실제 daemon·CLI·OS 자원 제어·자기 적용·CodeSpace runtime 결합은 미구현이다. 상세 계획이 생겼다고 후속 마일스톤을 완료로 바꾸지 않는다. 실제 이행 기록은 [DG-0](milestones/DG-0.md), 선택 근거와 immutable source는 [결정 기록](decisions.md)에 있다. +**계약 기준은 DG-0 회계·영속성·fake backend 시험이다.** DG-1은 현재 C01 정상 설정·저장소와 C02 인증 foreground transport를 제공하며 native 등록·workload 실행·자원 제어 qualification은 닫혀 있다. DG-1의 여섯 구현 PR은 순차 진행하도록 승인되었지만 승인 자체가 구현·검증 완료를 뜻하지 않는다. 실제 이행 기록은 [DG-0](milestones/DG-0.md), 선택 근거와 immutable source는 [결정 기록](decisions.md)에 있다. ## 읽는 순서와 문서 소유권 @@ -16,7 +16,7 @@ | 6. [검증 규칙](verification.md) | 무엇이 통과이고 어떤 증거를 남기는가 | 현재/예정 명령·검증 범위·SLO·재현/보존 | | 7. [PR 진행서](pr-delivery.md) | 문서와 구현 변경을 어떻게 전달하는가 | 이번 DGP/CSP commit·두 PR 순서·미래 인계 절차 | -[contracts.md](../../contracts.md)는 현재 구현된 DG-0 계약만 설명한다. [milestones.json](../../../milestones.json)은 ID·선행·상태의 원본이며 상세 문서 참조만 추가한다. 다른 문서는 작업 ID를 참조하고 상세 작업 정의를 복제하지 않는다. canvas는 저장소 문서와 실제 PR을 표시하는 보조 자료다. +[contracts.md](../../contracts.md)는 현재 구현된 계약을 설명한다. [milestones.json](../../../milestones.json)은 ID·선행·상태의 원본이며 각 마일스톤 문서는 작업 ID·커밋 경계·시험·증거·복귀 절차를 소유한다. 다른 문서는 작업 ID를 참조하고 상세 작업 정의를 복제하지 않는다. canvas는 저장소 문서와 실제 PR을 표시하는 보조 자료다. ## 마일스톤과 우선 경로 @@ -36,7 +36,7 @@ DG-1은 독립 CLI/daemon·개발 workload·자기 적용을 검증한다. CS-RG | 마일스톤 | 소유 | 예정 작업 commit 수 | 예정 PR 묶음 수 | 현재 구현 | | --- | --- | --- | --- | --- | | [DG-0](milestones/DG-0.md) | DevGuard | 실제 초기 commit 1개에 대한 이행 기록 | 과거 PR 재구성 없음 | 계약·fake backend 구현 | -| [DG-1](milestones/DG-1.md) | DevGuard | 12 | 6 | 미착수 | +| [DG-1](milestones/DG-1.md) | DevGuard | 12 | 6 | 진행 중: C01 설정·저장소와 C02 인증 transport; 실행 닫힘 | | [CS-RG](milestones/CS-RG.md) | CodeSpace | 8 | 4 | 미착수 | | [P1-RECOVERY](milestones/P1-RECOVERY.md) | CodeSpace | 6 | 3 | 미착수 | | [DG-LINUX](milestones/DG-LINUX.md) | DevGuard + CodeSpace | 6 | 3 | 미착수; 전체 제품 필수 | @@ -50,7 +50,7 @@ VM/container는 작업 단위에서 실제 Linux qualification 등 추가 조건 `DG1-C01` 같은 ID는 안정적인 예정 작업 ID이고 제목도 예정 값이다. 실제 commit SHA·PR URL은 생성 후 PR과 검증 report에서 연결한다. 이번 문서 작성의 DGP-D01~D04, CSP-D01~D02는 후속 runtime 46개에 포함하지 않는다. -각 작업의 현재 제공 명령은 기존 계약/회귀 범위만 확인한다. `scripts/qualify.py`와 `scripts/qualify-devguard.py`는 후속 구현이 제공할 예정 명령으로 지금 실행할 수 없다. 명령 이름이나 설정 파일만으로 실행이 governor를 통과했다고 판단하지 않는다. +`scripts/qualify.py dg1-authority`는 C01 경계를, `scripts/qualify.py dg1-auth`는 C02 로컬 인증·transport를 검증한다. 그 밖의 suite와 CodeSpace `scripts/qualify-devguard.py`는 후속 구현이 제공할 예정 명령이다. 명령 이름이나 설정 파일·인증 세션만으로 실행이 governor를 통과했다고 판단하지 않는다. 의존 등록은 실제 실행 소유자 Runner 한 곳에서 완료한다. source/client pin, 설치 daemon/helper artifact, 제품 wire, 이 계획을 인용하는 문서 revision을 별도 값으로 기록한다. 현재 pinned Codex `6b9826e3aa83b1a5947db50f4332cb9c65f1b340`과 Apache-2.0 라이선스는 유지한다. diff --git a/docs/ko/planning/milestones/DG-0.md b/docs/ko/planning/milestones/DG-0.md index edfbefb..114b054 100644 --- a/docs/ko/planning/milestones/DG-0.md +++ b/docs/ko/planning/milestones/DG-0.md @@ -15,7 +15,7 @@ | DG0-R05 | `pressure.rs`, `tests/pressure_contract.rs` | 첫 유효 probe 전 closed; 압력 상승·단계 회복 | stale/future/reboot sample, 관측 실패, 30초 회복, disk watermark | | DG0-R06 | `scripts/validate.py`, CI, 설계·계약·ledger | 정확한 source/toolchain/dependency 증거를 남김 | checksum·의존 경계·fmt·clippy·44개 계약 시험, runtime `not_run` | -진입 조건은 승인 설계와 Apache-2.0 저장소 설립, Rust 1.95.0/Python 3.11 이상이었다. 현재 workspace는 contract/core 두 crate다. `Backend`는 OS 증거를 받아들이는 추상 경계이며 시험 구현은 가짜다. transport 인증, daemon, helper, CLI, macOS 정책 적용, 실제 cgroup, 자기 적용, CodeSpace 런타임은 이행 범위 밖이다. +진입 조건은 승인 설계와 Apache-2.0 저장소 설립, Rust 1.95.0/Python 3.11 이상이었다. 초기 기준 workspace는 contract/core 두 crate다. `Backend`는 OS 증거를 받아들이는 추상 경계이며 시험 구현은 가짜다. transport 인증, daemon, helper, CLI, macOS 정책 적용, 실제 cgroup, 자기 적용, CodeSpace 런타임은 이행 범위 밖이다. ## 확인된 검증과 재현 diff --git a/docs/ko/planning/milestones/DG-1.md b/docs/ko/planning/milestones/DG-1.md index 0e5cd85..0c4a482 100644 --- a/docs/ko/planning/milestones/DG-1.md +++ b/docs/ko/planning/milestones/DG-1.md @@ -1,8 +1,10 @@ # DG-1 — macOS 개발 적용과 자기 적용 -소유 저장소: DevGuard. 상태: `not-started` / qualification `not-run`. 진입: DG-0 정확한 source의 계약 검증. 종료: 실제 macOS 실행·회수, generic/Cargo 소비, 상위 예산 안의 후보 시험, 독립 복구, 개발·foreground SLO를 통과한 artifact/정책/환경 조합 확보. +소유 저장소: DevGuard. 현재 구현 상태: `in-progress` / qualification `not-run`. 진입: DG-0 정확한 source의 계약 검증. 종료: 실제 macOS 실행·회수, generic/Cargo 소비, 상위 예산 안의 후보 시험, 독립 복구, 개발·foreground SLO를 통과한 artifact/정책/환경 조합 확보. -아래 ID·제목·PR 묶음은 **예정 값**이다. 실제 SHA나 GitHub PR 번호가 아니다. `crates/daemon`, `crates/client`, `crates/launcher`, `crates/platform-macos`, `crates/cli`, `crates/adapters`는 책임을 나타내는 **예정 경로**이며 현재 존재하지 않는다. crate 추가는 해당 PR에서 `scripts/validate.py`의 두-root allowlist와 전체 의존 그래프 검증을 함께 확장한다. 검사를 제거하지 않는다. +아래 ID·제목·PR 묶음은 **예정 값**이다. 실제 SHA나 GitHub PR 번호가 아니다. 모듈 경로는 구현 전까지 예정 책임을 나타낸다. 현재 daemon/client crate는 존재하며 launcher·platform-macos·cli·adapters는 후속 책임이다. crate 추가는 해당 PR에서 명시적 의존 allowlist와 전체 그래프 검증을 함께 확장하고 검사를 제거하지 않는다. 실제 상태는 ledger가 소유한다. + +구현된 C01은 정상 경로·명시 bootstrap·배타 journal 검사를 제공한다. C02는 foreground devguardd serve, 제한된 인증 UDS 통신, OS UID/PID 관측, 엄격한 client 호환성과 private 자격 FD 전달을 추가한다. PR과 병합 후 main 전달 증거는 별도로 추적한다. Native boot/start 정체성·등록/Principal·lease·정책·실행은 각 P2/P3 선행 조건이 구현될 때까지 닫혀 있다. [운영 문서](../../operations.md)를 참조한다. ## PR 순서와 활성화 경계 @@ -15,7 +17,9 @@ | DG1-P5 | DG1-C09, DG1-C10, DG1-C11 | DG1-P4 | 설치·후보·독립 복구를 묶어 자기 적용 활성화 | | DG1-P6 | DG1-C12 | DG1-P5 | 기능 기준 artifact와 SLO 안정 artifact를 구분해 승격 | -공통 현재 명령 `python3 scripts/validate.py --offline`은 DG-0 회귀를 확인한다. 아래 `python3 scripts/qualify.py `는 **미제공 예정 명령**이며 각 작업이 suite와 재현 fixture를 함께 구현해야 한다. 이름만 있는 테스트나 0개 실행을 통과로 처리하지 않는다. 공통 toolchain/증거/SLO 규칙은 상위 검증 문서에서 정의한다. +공통 현재 명령 python3 scripts/validate.py --offline은 Rust 1.95.0 계약 회귀를 확인하며 fake backend로 native 동작을 입증하지 않는다. C01 authority와 C02 인증·transport suite는 현재 제공한다. 아래에서 예정이라고 명시한 나머지 명령은 미제공이며 각 PR에서 fixture·실행 case 수·log·정리를 함께 구현하고 제공 상태를 갱신한다. 이름만 있는 테스트나 0개 실행을 통과로 처리하지 않는다. 공통 toolchain·증거·SLO 규칙은 상위 검증 문서에 있다. + +P1은 runtime을 닫아 두고 P2는 실제 probe, P3는 launch·안전 정리, P4는 개발 진입점, P5는 설치·부모 예산·repair, P6는 측정·승격을 제공한다. C08까지 foreground daemon과 최소 단일 Cargo job·test thread bootstrap을 사용한다. P4 bundle은 삭제할 build 경로 밖에 보존한다. C10에서 부모 예산을 포함한 artifact를 먼저 기능 시험·동결한 직후 제한된 실제 자기 적용을 시작하며 SLO qualification은 C12에서 확립한다. ### DG1-C01 — 운영 설정과 authority 경로 @@ -25,7 +29,7 @@ - 대상/산출물: 예정 daemon 설정 loader·경로 결정기·doctor 진단, 기존 `Authority::open` 경계와 설정 예시. symlink/소유권 검사를 포함한다. - 불변 조건: core 예산 산식·기존 journal init/open 분리·정적 예약·실행 capability closed. 설정 변경이 live consumer를 새 슬롯으로 취급하지 않는다. - 시험: 정상 단일 시작; 잘못된 권한/경로 별칭/누락 journal 거절; 동시 두 프로세스와 다른 socket 이름이 하나의 정상 authority만 얻는 경쟁. -- 검증 명령: 현재 공통 회귀 + 예정 `python3 scripts/qualify.py dg1-authority`. +- 검증 명령: 현재 공통 회귀 + 제공되는 `python3 scripts/qualify.py dg1-authority --offline`. 설정·저장소 검증이며 native 제어 자격은 아니다. - 완료 증거: 경로·UID·lock 소유 관측, 중복 시작 거절 로그, 설정 fingerprint. credentials와 전체 개인 경로 로그는 정제한다. - rollback: 서비스 시작을 중지하고 기존 journal을 보존; live instance 설정을 임의 축소하지 않는다. - 인계: DG1-C02에 정상 transport endpoint와 관리/시험 모드 판별을 전달. C02와 함께 PR을 제출한다. @@ -35,13 +39,13 @@ - 소유/예정 PR: DevGuard / DG1-P1. 예정 제목: `feat(client): authenticate local peers and transfer scoped credentials`. - 문제 → 동작: 호출자가 선언한 PID/UID 대신 OS peer 관측을 사용하고 version/capability를 검증한 client protocol을 제공한다. - 선행: DG1-C01. 실제 UDS peer 확인 가능 환경, consumer generation·secret 설치 경로. 아직 사용자 명령 실행은 제공하지 않는다. -- 대상/산출물: 예정 daemon/client framing, credential FD API, handshake fixture; `TrustedPeer` 생성은 daemon 내부에 제한한다. -- 불변 조건: payload에 peer identity/관리 Principal 선언 불가; UID만으로 권한 획득 불가; secret은 argv/env/journal/debug에 저장하지 않는다. -- 시험: 정상 같은 consumer 인증; 잘못된 UID/PID·credential·generation·wire/capability 거절; 재접속·동시 슬롯 등록·요청 재전송; FD/로그 secret 누출 확인. -- 검증 명령: 현재 공통 회귀 + 예정 `python3 scripts/qualify.py dg1-auth`. -- 완료 증거: peer 관측과 등록 identity 대응, 권한 오류 표, N/N+1 decoding fixture 결과. 필드 추가의 호환성도 실제 시험한다. +- 대상/산출물: daemon/client framing·foreground 서비스·private credential FD API·handshake와 strict-decoding fixture. C02는 peer UID/PID를 관측한다. Daemon은 C03이 Backend를 통해 boot/start 증거를 제공한 뒤에만 TrustedPeer를 native 등록에 연결하며 TrustedPeer 자체에는 UID/PID만 있다. +- 불변 조건: payload에 peer identity/관리 Principal 선언 불가; UID만으로 역할 획득 불가; 모든 consumer·관리 digest 분리; helper permit을 caller 자격으로 사용 금지. Secret을 argv/env/journal/debug에 남기지 않고 payload 64 KiB·세션 32개·idle 대기를 포함한 frame별 절대 250 ms 기한을 지킨다. 인증은 동일 UID 공격자를 격리하거나 Principal·lease를 발급하지 않는다. +- 시험: 정상 인증·status·재접속; 잘못된 UID/PID·credential·generation·wire/capability 거절; 동시 등록 요청은 모두 닫힌 상태 유지; 부분·느린·마지막 frame과 포화·후속 exec 전 private FD 닫기. C05 helper qualification은 아니다. +- 검증 명령: 현재 공통 회귀 + 현재 제공되는 python3 scripts/qualify.py dg1-auth --offline. Transport·저장소 동작을 입증하며 native 자원·SLO 자격은 부여하지 않는다. +- 완료 증거: 양 끝에서 대조한 OS peer 관측, 권한 오류 표, 필드 추가 비호환성을 포함한 구신 decoding 결과. Native 등록은 not_run이다. 설정 schema 1에서 이전 시스템 task 예약 16은 거절하고 최소 48(session 32+서비스/관제 여유 16)을 요구한다. 자동 migration·kernel 제한 주장이 아닌 명시적 운영자 용량 검토를 문서화한다. - rollback: 신규 연결/admission을 닫고 기존 lease를 유지; 자격 회전은 live generation 대조 이후 수행한다. -- 인계: DG1-C03~C06이 신뢰할 client principal과 private FD API. 인증과 canonical 경로를 분리 활성화하지 않는다. +- 인계: DG1-C03~C06에 OS 인증 세션·private FD API를 전달하고 C03이 boot/start 정체성을 제공한 뒤 native Principal을 생성한다. 인증과 canonical 경로를 분리 활성화하거나 통신 장애로 실행·회수를 추정하지 않는다. ### DG1-C03 — 호스트 probe와 프로세스 정체성 diff --git a/docs/ko/planning/verification.md b/docs/ko/planning/verification.md index 172aacd..482f751 100644 --- a/docs/ko/planning/verification.md +++ b/docs/ko/planning/verification.md @@ -8,7 +8,7 @@ | --- | --- | --- | --- | | V-DOC-DG | 이번 DevGuard 문서/메타데이터 | checksum·license·ID·DAG·링크·46작업/23묶음·필수 항목·상태 보존 | 문서 검토 및 아래 재현 검사 가능 | | V-DG0 | contract/core, DevGuard | Rust1.95.0 fmt/clippy·44개 계약·의존 graph·source fingerprint | 기존 validator 제공 | -| V-DG1-FUNCTION | 실제 auth/probe/launch/reconcile/CLI | DG1-C01~C11 정상·실패·경쟁 및 기준 artifact | 미구현; DG1 각 작업에서 suite 제공 | +| V-DG1-FUNCTION | 실제 auth/probe/launch/reconcile/CLI/운영 | DG1-C01~C11 정상·실패·경쟁 및 기능 artifact | C01 authority·C02 로컬 인증/transport 제공; 후속 범위는 각 묶음에서 제공 | | V-DG1-SLO | 독립 CLI/daemon·개발·self-use | DG1-C12 개발/foreground 및 standalone control 측정 | 미구현; CS-RG 기능을 선행 요구하지 않음 | | V-CS-DOC | CodeSpace 한·영 registry/site | paired hash·기존 docs tests·고정 환경 build·integrity·화면 검토 | 기존 명령 제공 | | V-CS-UPSTREAM | CodeSpace 기존 Codex qualification | pin/policy/format/dependency/adapter/PTY/filesystem/platform gates | 기존 제공, 실제 platform별 수행 | @@ -24,7 +24,10 @@ DevGuard root, Rust **1.95.0**(rustfmt/Clippy 포함), Python 3.11 이상: ```sh +python3 scripts/check_docs.py python3 scripts/validate.py --offline +python3 scripts/qualify.py dg1-authority --offline +python3 scripts/qualify.py dg1-auth --offline git diff --check ``` @@ -66,7 +69,11 @@ V-DOC-DG는 scripts/check_docs.py와 수동 의미 검토로 영어/한국어 ha ## 향후 suite와 장애 주입 계약 -마일스톤의 `scripts/qualify.py` 및 CodeSpace `scripts/qualify-devguard.py` 명령은 **예정 인터페이스**다. 현재 파일이 없으므로 지금 실행할 수 없다. 각 구현 PR이 실제 CLI·case inventory·nonzero case assertion·timeout·log 수집·격리 cleanup을 구현하고 문서의 명령을 최종 형태로 고정해야 한다. +scripts/qualify.py dg1-authority --offline은 C01 설정·저장소, scripts/qualify.py dg1-auth --offline은 C02 인증·transport 검증을 제공한다. 그 밖의 DevGuard suite와 CodeSpace scripts/qualify-devguard.py는 해당 작업에서 제공할 예정 인터페이스다. 각 구현 PR이 실제 CLI·case inventory·nonzero case assertion·timeout·log 수집·격리 cleanup을 구현하고 제공 명령을 갱신해야 한다. macOS/Ubuntu CI는 전체 validator를 유지하며 두 기능 suite도 실행하고 각각의 report·log를 보존한다. + +C02 증거는 양쪽 실제 OS socket UID/PID 관측, 분리된 consumer·관리 자격, helper-role 인증 거절, 현재·미래 wire fixture의 엄격한 decoding, 64 KiB frame, 세션 32개 제한, idle 대기를 포함한 frame별 절대 250 ms 기한, 부분·느린·마지막 응답과 private 자격 FD 전달을 포함한다. 전용 subprocess helper는 후속 exec 전 FD 닫기를 관측하고 argv·환경·debug·출력의 secret 누출을 확인한다. Parent test가 helper를 실행하며 별도의 ignored test를 독립 qualification 성공으로 세지 않는다. 0개가 아닌 parent case 수와 프로세스 정리를 함께 기록한다. + +Framing은 poll·descriptor O_NONBLOCK·호출별 nonblocking I/O로 Darwin timeout 옵션 변경 없이 peer 종료 뒤 버퍼 데이터를 보존한다. 느린 writer와 느린 reader를 모두 시험한다. 인증과 닫힌 등록 응답은 boot/start 정체성, native 등록/Principal, lease, OS 정책, helper 권한·launch를 입증하지 않으며 해당 범위는 P2/P3까지 미검증이다. system_tasks >= 48도 검증된 회계 추정치이지 kernel task 제한이나 측정된 충분성이 아니다. 같은 schema 1에서 이전 값 16을 거절하는 시험을 명시하며 자동 migration을 의미하지 않는다. | 시험 영역 | 주입 지점/반드시 보존할 불변 조건 | 담당 작업 | | --- | --- | --- | diff --git a/docs/milestones.md b/docs/milestones.md index 5dc9860..1cba518 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -7,14 +7,14 @@ The [detailed planning index](planning/README.md) owns the 46 proposed commit un | Milestone | Deliverable and gate | Current implementation | |---|---|---| | DG-0 | Independent repository, approved design, resource and compatibility contracts, durable state transitions, fake-backend fault tests, reproducible 1.95.0 validation | Implemented; use the exact-source qualification report for validation status | -| DG-1 | Real macOS host probes, daemon and CLI, launch gate, Cargo/generic adapters, parent-budget candidate tests, safe service update/repair, three measured SLO runs per target backend | Not started | +| DG-1 | Real macOS host probes, daemon and CLI, launch gate, Cargo/generic adapters, parent-budget candidate tests, safe service update/repair, three measured SLO runs per target backend | In progress: C01 configuration/storage and C02 authenticated foreground transport; execution and SLO unqualified | | CS-RG | Full-SHA consumer pin, pre-spawn slots, PrepareExec/ExecPrepared, approval preservation, bounded control/data lanes and replay, InProcess/UDS parity and upstream regression qualification | Not started | | P1-RECOVERY | Opt-in Gateway restart/reconnection while an independent Runner retains processes and I/O; reconcile workspace, approvals and DevGuard leases | Not started; depends on CS-RG; InProcess and Runner-loss I/O restoration excluded | | DG-LINUX | Actual Linux controller, ancestor capacity, complete sandbox/proxy scope, control protection and reclaim evidence | Not started; required for product completion | | DG-CACHE | Registered-root lease/reclaim exclusion, protected artifacts/evidence, interrupted trash sweep and measured physical recovery | Not started; not a P1 prerequisite | | DG-ADAPTERS | Tool-specific Python, Node/Bun, make/ninja and container/VM verification, without language branches in core | Not started; not a P1 prerequisite | -DG-0's SQLite and fake-backend tests do not qualify a running resource governor. The `devguard` CLI, `devguardd`, `devguard-launch`, CodeSpace `resources` configuration and Runner wire 7 remain future work. The approved design's examples must not be represented as currently runnable commands. +DG-0's SQLite and fake-backend tests do not qualify a running resource governor. The `devguardd` bootstrap/path/check commands and C02 foreground `serve` with authenticated status are available. Native registration and execution remain closed. The execution CLI, launch helper, CodeSpace resources settings and Runner wire changes remain future work; see [operations](operations.md). The approved design's examples must not be represented as currently runnable commands. Record design acceptance, implementation and platform qualification separately. Do not change Linux `not_run` to passed because a fake cgroup test passed on macOS, or call normal Cargo bootstrap self-governed development. The reports from `scripts/validate.py` include those distinctions explicitly. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..8f8f239 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,92 @@ +# Operating the DevGuard service boundary + +[English](operations.md) | [한국어](ko/operations.md) + +C01 provides explicit bootstrap/canonical storage and C02 provides authenticated local transport. PR and post-merge main delivery evidence is tracked separately from implementation. `devguardd serve` runs a foreground service; native registration, principals, resource leases and execution remain closed until P2/P3 provide actual host identity/probes and launch/reconciliation. The normal authority is currently macOS-only; Linux CI checks portable contracts and bounded fixtures, not DG-LINUX controls. + +## Available commands + +Build with Rust 1.95.0 using one Cargo job during bootstrap: + +```sh +CARGO_BUILD_JOBS=1 cargo build --locked -p devguard-daemon +target/debug/devguardd paths +target/debug/devguardd init +target/debug/devguardd check +target/debug/devguardd serve +``` + +`paths` only observes the operating account. `init` is an explicit first-time bootstrap, creating a new journal, operator configuration and separate CLI/administrative credentials. It refuses existing state and never overwrites it. `check` obtains exclusive storage ownership, validates configuration/journal and reports `runtime_ready: false`; it does not fabricate a boot clock or claim recovery/application. It cannot acquire a second authority while another owner holds the lock. + +`serve` opens the existing validated journal, acquires exclusive ownership and binds the canonical private UDS endpoint. Stop this foreground process with Ctrl-C or SIGTERM; shutdown closes sessions and removes only its own socket inode while preserving the journal, lock and credentials. A stale socket is removed only under the exclusive authority lock after a connection attempt positively returns connection refused and the inode remains unchanged. A live, busy or unobservable endpoint is not removed. + +Authenticated status reports `storage_validated: true`, `registration_ready: false`, `execution_ready: false`, a reason and the configuration fingerprint. Generic execution, installation, a LaunchAgent and repair remain future work. Configuration or a successful handshake alone does not govern commands. Necessary bootstrap builds are not self-use evidence. Do not use the disposable `target` path for a persistent service; protected installation is C09 work. + +## Canonical ownership and paths + +Normal paths derive from the OS account database, independent of caller `HOME`, XDG, socket or state overrides. Root/setuid execution is refused. There is no production alternate-path or unparented test-budget argument. Candidate paths require the later C10 parent-budget protocol; only compiled test fixtures can construct isolated fixture roots. + +| Purpose | macOS path | +| --- | --- | +| Operator configuration | `~/.config/devguard/host.toml` | +| Persistent authority | `~/Library/Application Support/DevGuard/state/authority.sqlite` | +| Persistent exclusive lock | `~/Library/Application Support/DevGuard/state/authority.lock` | +| Registration/admin credentials | Separate files under `~/Library/Application Support/DevGuard/credentials/` | +| Runtime endpoint | `/private/tmp/devguard-/authority.sock` | +| Future cache | `~/Library/Caches/DevGuard/` | + +DevGuard directories are private (0700), files 0600, with owner/type and symlink checks. Parent traversal, unsafe ancestors, linked private files and shared permissions fail closed; the service does not silently chmod user paths. Shared sticky tmp is permitted only as an ancestor of the private runtime directory. The journal and lock remain outside tmp. + +Lock opening uses `O_NONBLOCK`; after opening, descriptor metadata must confirm a current-UID private regular file with one link, including during explicit initialization. FIFO and hard-link fixtures must fail without blocking or granting authority ownership. + +Ordinary startup requires existing persistent directories, journal and lock. Missing/corrupt/unsupported state is never initialized implicitly. If explicit bootstrap partially fails, preserve partial files for diagnosis and explicit repair; rerunning init is not repair. Do not remove an active lock, journal, credential or recovery artifact to make startup succeed. + +## Configuration authority + +The bounded UTF-8 TOML operator file is schema 1 and rejects unknown fields. It defines policy revision, the interactive profile, consumer generations/credential digests/roles/instance limits, static control reservations and registered project roots. Plaintext credentials are stored only in their separate private files. All consumer and administrative credential digests must differ; a workload role cannot grant itself a control reservation. Parsing errors never echo source text or secrets. + +The initial `dev-cli` registration allows eight instances and adds no per-instance control reservation: the approved aggregate CLI control pool is counted centrally when native accounting becomes available. The task fields start at an **accounting estimate** of capacity 256, host headroom 64 and system reservation 48. `system_tasks` must be at least 48, covering 32 bounded session workers plus 16 tasks of service/control headroom. These are configurable operator ceilings, not macOS kernel limits or measured sufficiency; C12 must record and qualify the selected values. CPU/memory headroom and control defaults retain the approved design and will use actual native capacity in C03. Additional headroom can only subtract capacity. + +C01 configurations that used the former `system_tasks = 16` default are rejected by C02. Configuration schema remains 1; this is a stricter semantic requirement, not automatic compatibility or migration. Before starting C02, an operator must review total task capacity, headroom and all reservations, then explicitly choose `system_tasks >= 48` within that capacity. Preserve the existing journal and credentials. Do not rerun `init`, silently enlarge host capacity or reduce a live reservation to make validation pass. + +A project `.devguard.toml` has only schema, project ID, profile, adapter selection and optional tighter `Budget` limits (`cpu_milli`, `memory_bytes`, `tasks`). It cannot specify credentials, roles, another authority, a consumer identity or host capacity. The project limit must fit the operator limit; unsupported fields/adapters/versions are refused. No project-specific settings enter authority core. + +```toml +schema = 1 +project_id = "devguard-dev" +profile = "interactive" +adapter = "cargo" + +[limits] +cpu_milli = 1000 +memory_bytes = 2147483648 +tasks = 32 +``` + +This is a configuration example, not evidence that execution already consumes a lease. Register actual absolute project roots in operator configuration before later CLI adoption. Projects and credentials do not create another host budget. + +## Client authentication and transport limits + +The client library connects to the canonical endpoint, checks the OS-observed authority UID/PID, negotiates wire version 1 and corroborates its own UID/PID in the handshake. Consumer ID/generation/secret authenticate a configured workload or control-service role; a separate secret authenticates the administrative role. UID equality alone grants no role. Helper permits cannot be used as caller credentials, and this authentication does not isolate malicious processes sharing the same UID. + +The wire uses a four-byte length prefix, at most 64 KiB per JSON payload, at most 32 active sessions and an absolute 250 ms deadline for each frame read/write. That deadline includes idle waiting before a new frame, so idle connections expire. Use a fresh authenticated session when explicitly beginning a later operation; the client does not automatically reconnect or retry. Its `poll` and nonblocking descriptor I/O handle partial frames, slow readers and buffered final responses without changing Darwin timeout options after peer closure. No response loss is treated as proof of execution, nonexecution or resource release. + +The private-FD API passes only a descriptor identifier through startup metadata. The receiver consumes and closes the credential FD before a later `exec`; tests exercise that boundary in real subprocesses. Do not put secrets in argv, environment, debugging or payload-inherited descriptors. These tests do not establish C05 helper authorization or payload startup. OS UID/PID observations are available now; native boot/start identity and instance registration require C03. No authenticated caller can obtain a principal, lease or execution grant from C02. + +The SDK inspection example can be built with `CARGO_BUILD_JOBS=1 cargo build --locked -p devguard-client --example inspect`. Its interface is `inspect SOCKET UID CONSUMER GENERATION CREDENTIAL_FD`: a parent must supply the secret bytes through that dedicated inherited FD, while arguments carry only the FD number and non-secret connection metadata. It prints observed peers and authenticated status. It is a status example, not the future generic execution CLI. + +## Compatibility, checks and rollback + +Core `AuthorityStorage` holds the original exclusive lock and validates the existing schema-1 journal without changing attempt state. Activation with a real `Backend`/`Clock` revalidates accounting in the same transaction as boot-aware recovery. Existing `Authority::open` preserves its recovery behavior and the DG-0 tests. C01/C02 do not change the journal or existing contract serialization. The new local protocol strictly rejects unknown fields, versions and unsupported required capabilities; added fields need explicit compatibility tests. + +Available checks: + +```sh +python3 scripts/qualify.py dg1-authority --offline +python3 scripts/qualify.py dg1-auth --offline +python3 scripts/validate.py --offline +``` + +Functional suites reject zero executed cases and record source fingerprints, toolchain, bootstrap mode and logs. `dg1-authority` checks exclusive startup, aliases/permissions, absent/corrupt/future journals, activation-time corruption, strict configuration versions, project escalation and concurrent bootstrap. `dg1-auth` checks actual peer observations, authentication roles, strict frames, bounded communication and private FD hygiene, including concurrent requests whose registration remains closed. These leave native registration/launch, Linux enforcement, self-use and foreground SLO `not_run`. The full validator retains the 44 original tests and validates all four workspace crates and their explicit dependency graph. Core/contract remain independent of daemon configuration, and client does not depend on core. + +To roll back this boundary, stop its task-owned foreground process and select a source/artifact compatible with the preserved configuration and schema-1 journal. Keep persistent state and credentials. No workloads can have started through C01/C02. Later live-lease rollback requires actual reconciliation; it cannot use this early empty-state assumption. diff --git a/docs/planning/README.md b/docs/planning/README.md index 4619225..5e82679 100644 --- a/docs/planning/README.md +++ b/docs/planning/README.md @@ -3,7 +3,7 @@ Reference date: 2026-09-22. Repository: `/Volumes/DevData/Projects/IdeaProjects/DevGuard`. English is the authoritative editorial source. [Reviewed Korean translations](../ko/planning/README.md) are maintained through the [translation registry](../translations.json). -The implemented baseline is DG-0: contracts, accounting, persistence and fake-backend tests. The six DG-1 implementation PRs are authorized for sequential delivery; authorization does not establish implementation or qualification. The [ledger](../../milestones.json) owns milestone IDs, dependencies and status. Individual milestone documents own work IDs, commit boundaries, tests, evidence and rollback. +The contract baseline is DG-0: accounting, persistence and fake-backend tests. DG-1 now includes C01 canonical configuration/storage and C02 authenticated foreground transport; native registration, workload execution and resource-control qualification remain closed. The six DG-1 implementation PRs are authorized for sequential delivery; authorization does not establish implementation or qualification. The [ledger](../../milestones.json) owns milestone IDs, dependencies and status. Individual milestone documents own work IDs, commit boundaries, tests, evidence and rollback. ## Reading order and ownership @@ -35,7 +35,7 @@ DG-1 qualifies standalone daemon/CLI, development workloads and bounded self-use | Milestone | Owner | Proposed work commits | Logical PR groups | Baseline state | | --- | --- | --- | --- | --- | | [DG-0](milestones/DG-0.md) | DevGuard | One actual initial commit, documented retrospectively | No invented historical PRs | Implemented contract/fake scope | -| [DG-1](milestones/DG-1.md) | DevGuard | 12 | 6 | Not started | +| [DG-1](milestones/DG-1.md) | DevGuard | 12 | 6 | In progress: C01 configuration/storage and C02 authenticated transport; execution closed | | [CS-RG](milestones/CS-RG.md) | CodeSpace | 8 | 4 | Not started | | [P1-RECOVERY](milestones/P1-RECOVERY.md) | CodeSpace | 6 | 3 | Not started | | [DG-LINUX](milestones/DG-LINUX.md) | DevGuard and CodeSpace | 6 | 3 | Not started; required overall | @@ -47,7 +47,7 @@ DG-1 qualifies standalone daemon/CLI, development workloads and bounded self-use IDs such as `DG1-C01`, commit titles and logical PR labels are proposed values. Record real SHAs and PR URLs only after creation. Documentation work DGP-D01–D04 and CSP-D01–D02 is separate from these 46 units; append-only preparation changes preserve their history and immutable links. -`scripts/qualify.py` and CodeSpace's `scripts/qualify-devguard.py` are planned interfaces until their implementation PR provides them. A configuration file alone does not prove that a command consumes the central budget. +`scripts/qualify.py dg1-authority` verifies the C01 boundary, and `scripts/qualify.py dg1-auth` verifies C02 local authentication/transport. Other qualification suites and CodeSpace's `scripts/qualify-devguard.py` remain planned until their implementation PR provides them. A configuration file or authenticated session alone does not prove that a command consumes the central budget. For DG-1, complete one PR through review, current-head checks, normal merge, push-triggered main checks and cleanup before beginning the next. Use one Cargo job and one test thread for necessary bootstrap work. At DG1-C10, validate and freeze a parent artifact containing the new parent-budget capability, then immediately start bounded real self-use. A C08/C09 functional artifact is not presumed to implement C10 operations, and the C10 parent is not an SLO-qualified release until C12 passes. diff --git a/docs/planning/milestones/DG-1.md b/docs/planning/milestones/DG-1.md index 59473b2..6f1e1dc 100644 --- a/docs/planning/milestones/DG-1.md +++ b/docs/planning/milestones/DG-1.md @@ -1,9 +1,11 @@ # DG-1 — macOS development and bounded self-use -Owner: DevGuard. Baseline implementation: `not-started`; qualification: `not-run`. Entry: DG-0. Completion: DG1-C12 qualifies actual macOS launch/reconciliation, generic/Cargo consumption, parent-budget self-use, independent repair and development/foreground SLO for the measured artifact/policy/environment. +Owner: DevGuard. Current implementation: `in-progress`; qualification: `not-run`. Entry: DG-0. Completion: DG1-C12 qualifies actual macOS launch/reconciliation, generic/Cargo consumption, parent-budget self-use, independent repair and development/foreground SLO for the measured artifact/policy/environment. All work IDs, commit titles and logical PR labels below are **proposed values**, not future SHAs or GitHub numbers. Module paths describe planned responsibilities until implemented. Each added workspace crate updates the explicit dependency allowlist in the same PR without removing full-graph validation. The ledger owns actual status. +Implemented behavior: C01 provides canonical paths, explicit bootstrap and locked journal validation. C02 adds foreground `devguardd serve`, authenticated bounded UDS communication, OS-observed UID/PID, strict client compatibility and private credential-FD transfer. PR and post-merge main delivery evidence is tracked separately. Native boot/start identity, registration/principals, leases, policies and execution remain closed until their P2/P3 prerequisites are implemented. See [operations](../../operations.md). + ## PR sequence and activation | Proposed group | Units | Predecessor | @@ -15,7 +17,7 @@ All work IDs, commit titles and logical PR labels below are **proposed values**, | DG1-P5 | DG1-C09, DG1-C10, DG1-C11 | DG1-P4 | | DG1-P6 | DG1-C12 | DG1-P5 | -Available regression: `python3 scripts/validate.py --offline` (Rust 1.95.0 contract regression; fake backends do not prove native behavior). The task-specific qualification commands below are **planned and unavailable until implemented**. Each PR must supply real fixtures, nonzero case counts, logs and cleanup, then update command availability. See [verification](../verification.md). +Available regression: `python3 scripts/validate.py --offline` (Rust 1.95.0 contract regression; fake backends do not prove native behavior). C01 authority and C02 authentication/transport suites are now available; commands explicitly labelled planned below remain unavailable until implemented. Each PR must supply real fixtures, nonzero case counts, logs and cleanup, then update command availability. See [verification](../verification.md). DG1-P1 keeps runtime readiness closed; P2 provides actual probes; P3 ships launch with safe cleanup; P4 provides development entrypoints; P5 installation/parent-budget/repair; P6 measures and promotes. Through C08 use foreground daemons and minimum one-job/one-thread bootstrap. Preserve the P4 bundle outside disposable output. At C10, first test and freeze a parent containing parent-budget support, then immediately begin bounded real self-use; C12 alone establishes SLO qualification. @@ -30,20 +32,20 @@ DG1-P1 keeps runtime readiness closed; P2 provides actual probes; P3 ships launc - Completion evidence: Observed paths/UID/lock owner, rejected duplicate-start logs and configuration fingerprint; redact credentials and private path detail. - Rollback: Stop service startup while preserving the journal; do not shrink live-instance policy arbitrarily. - Handoff: DG1-C02 receives the canonical endpoint and administrative/test-mode boundary; deliver both in DG1-P1. -- Verification command: available regression above plus **planned, not yet provided** `python3 scripts/qualify.py dg1-authority`. +- Verification command: available regression above plus **available** `python3 scripts/qualify.py dg1-authority --offline`. This checks configuration/storage, not native controls. ### DG1-C02 — authenticate local peers and transfer scoped credentials - Owner / proposed PR: DevGuard / DG1-P1. Proposed commit: `feat(client): authenticate local peers and transfer scoped credentials`. - Problem → behavior: Authenticate OS-observed peers rather than caller-declared UID/PID, with bounded versioned client communication. - Prerequisites: DG1-C01. Real UDS peer inspection and installed generation/registration secrets; user execution is not yet enabled. -- Modules / deliverables: Daemon/client framing, private credential FD API, handshake/strict-decoding fixtures; construct TrustedPeer only at the trusted daemon boundary. -- Invariants: Caller payload cannot declare peer identity/admin Principal; UID alone grants no role; keep secrets out of argv/env/journal/debug and bound frames, sessions and deadlines. -- Tests (normal / failure / race): Normal registration/reconnect; wrong UID/PID, secret, generation, wire or capability rejected; concurrent registration/retransmission and FD/log leakage tests. -- Completion evidence: OS peer-to-instance correspondence, authorization error matrix and old/new decoding results, including added-field incompatibility. +- Modules / deliverables: Daemon/client framing, foreground service, private credential FD API and handshake/strict-decoding fixtures. C02 observes peer UID/PID. The daemon connects TrustedPeer to native registration only after C03 supplies boot/start evidence through Backend; TrustedPeer itself contains only UID/PID. +- Invariants: Caller payload cannot declare peer identity/admin Principal; UID alone grants no role; all consumer/admin digests differ; helper permits are not caller credentials. Keep secrets out of argv/env/journal/debug, bound payloads to 64 KiB and sessions to 32, and enforce an absolute 250 ms per-frame deadline including idle wait. Authentication does not isolate malicious same-UID programs or issue a principal/lease. +- Tests (normal / failure / race): Normal authentication/status/reconnect; wrong UID/PID, secret, generation, wire or capability rejected; concurrent registration attempts all remain closed; partial/slow/final frames, saturation and private FD closure before subsequent exec. This is not C05 helper qualification. +- Completion evidence: OS peer observations corroborated at both ends, authorization error matrix and old/new decoding results, including added-field incompatibility. Native registration stays `not_run`. Configuration schema 1 now rejects the former system task reservation 16 and requires at least 48 (32 sessions plus 16 service/control headroom); document explicit operator capacity review without migration or kernel-limit claims. - Rollback: Close new connections/admission, preserve existing leases and rotate credentials only after live-generation reconciliation. -- Handoff: DG1-C03–C06 consume authenticated principals/private FD APIs; never activate auth separately from canonical ownership. -- Verification command: available regression above plus **planned, not yet provided** `python3 scripts/qualify.py dg1-auth`. +- Handoff: DG1-C03–C06 receive OS-authenticated sessions and private FD APIs; C03 supplies boot/start identity before creating native principals. Never activate auth separately from canonical ownership or infer execution/release from communication failure. +- Verification command: available regression above plus **available** `python3 scripts/qualify.py dg1-auth --offline`. It establishes transport/storage behavior only; native resources and SLOs remain unqualified. ### DG1-C03 — observe boot identity and host pressure diff --git a/docs/planning/verification.md b/docs/planning/verification.md index 37d06c9..f227b7e 100644 --- a/docs/planning/verification.md +++ b/docs/planning/verification.md @@ -8,7 +8,7 @@ Preserve the approved [design SLOs](../design.md#verification-and-promotion). De | --- | --- | --- | --- | | V-DOC-DG | DevGuard docs/metadata | Original checksum/license, English/Korean hashes, IDs/DAG/links, 46 units/23 groups, required fields | Documentation checker and review | | V-DG0 | Contract/core | Rust 1.95.0 fmt/Clippy, 44-test baseline, full dependency graph and source fingerprint | Existing validator | -| V-DG1-FUNCTION | Real auth/probe/launch/reconcile/CLI/operations | DG1-C01–C11 normal/failure/race cases and functional artifacts | Supplied with each implementation group | +| V-DG1-FUNCTION | Real auth/probe/launch/reconcile/CLI/operations | DG1-C01–C11 normal/failure/race cases and functional artifacts | C01 authority and C02 local authentication/transport available; later scopes supplied with each group | | V-DG1-SLO | Standalone daemon/CLI, development and self-use | DG1-C12 control and foreground measurements | Planned; no CS-RG prerequisite | | V-CS-DOC | CodeSpace bilingual registry/site | Reviewed hashes, existing tests, pinned build, integrity and visual review | Existing commands | | V-CS-UPSTREAM | Existing Codex integration | Pin/policy/format/dependencies/adapter/PTY/filesystem/platform gates | Existing; actual platforms required | @@ -26,6 +26,8 @@ DevGuard requires Rust **1.95.0** with rustfmt/Clippy and Python 3.11 or later: ```sh python3 scripts/check_docs.py python3 scripts/validate.py --offline +python3 scripts/qualify.py dg1-authority --offline +python3 scripts/qualify.py dg1-auth --offline git diff --check ``` @@ -63,7 +65,11 @@ For the preparation PRs, preserve runtime/Cargo/journal state. Documentation che ## Planned suites and fault injection -`scripts/qualify.py ` and CodeSpace `scripts/qualify-devguard.py ` are planned interfaces, not currently runnable commands. Each implementation PR supplies the actual interface, nonzero case inventory, timeouts, logs, isolation and cleanup, then updates its task command documentation. +`scripts/qualify.py dg1-authority --offline` is available for C01 configuration/storage, and `scripts/qualify.py dg1-auth --offline` for C02 authentication/transport. Other DevGuard suites and CodeSpace `scripts/qualify-devguard.py ` remain planned interfaces until supplied by their work units. Each implementation PR supplies the actual interface, nonzero case inventory, timeouts, logs, isolation and cleanup, then updates its task command documentation. macOS/Ubuntu CI retains the full validator and also runs both functional suites, preserving their separate reports and logs. + +C02 evidence covers actual OS socket UID/PID observations at both ends, distinct consumer/admin credentials, rejected helper-role authentication, strict current/future wire fixtures, 64 KiB frames, a 32-session limit, absolute 250 ms per-frame deadlines including idle waits, partial/slow/final responses and private credential-FD transport. Dedicated subprocess helpers verify FD closure before a subsequent exec and inspect argv/environment/debug/output for secret leakage. They are executed by parent tests and are not independent ignored qualification successes. Record process cleanup as well as the nonzero parent-case inventory. + +The framing implementation uses `poll` with descriptor `O_NONBLOCK` and per-call nonblocking I/O, preserving buffered data after peer closure without Darwin timeout-option mutation. Test slow readers as well as slow writers. Authentication and a closed registration response do not prove boot/start identity, native registration/principals, leases, OS policy application, helper authorization or launch. Those remain unqualified until P2/P3. Likewise, `system_tasks >= 48` is a validated accounting estimate, not a kernel task cap or a measured sufficiency claim. Explicitly test rejection of the previous value 16 under unchanged schema 1; no automatic migration is implied. | Area | Required faults/invariants | Work | | --- | --- | --- | diff --git a/docs/translations.json b/docs/translations.json index 2fd02c1..d495598 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -14,8 +14,8 @@ "id": "planning-readme", "source": "docs/planning/README.md", "translation": "docs/ko/planning/README.md", - "reviewed_source_sha256": "303629cb277193896176f3f2d0d199f2a52374d81fbe4f7ac4da86a21710b24f", - "reviewed_translation_sha256": "077c1c952c624d21eb85042610a17d4941ad3248a3d1fc56f0eb2bcfe0e8d5ce" + "reviewed_source_sha256": "55673426697bd5c51c4d2793c4f932a681b12b2335173afa59e24c7cc133524b", + "reviewed_translation_sha256": "2fdaeb35ac1ae945180956fe0138bb18bbe8ed06ed86fbc5ff41546236e16c58" }, { "id": "planning-codespace-integration", @@ -50,14 +50,14 @@ "source": "docs/planning/milestones/DG-0.md", "translation": "docs/ko/planning/milestones/DG-0.md", "reviewed_source_sha256": "abe48a99b4b5c360b785c0f1bc0fbb3ad9b039533085d6ce857f2af40ac7370d", - "reviewed_translation_sha256": "82af5021faef3730e17d76c28a60bd73b2b74bf3b8b544249c15024fc1e4ade3" + "reviewed_translation_sha256": "cd40e42e483160f2d2e6ce24da24505b3cb564fa7d132fcfd88d26d72a98e4fc" }, { "id": "planning-milestones-dg-1", "source": "docs/planning/milestones/DG-1.md", "translation": "docs/ko/planning/milestones/DG-1.md", - "reviewed_source_sha256": "a1017967f9040497f59f856bf0b2ff4cb4c8a3adc121141e226e76832d4e0b4a", - "reviewed_translation_sha256": "0ec8487cdf8c6cd5763ab5ca7c29ccd0c790e3974f025d2455aa1323fe88b2cf" + "reviewed_source_sha256": "de6d571a80be0156e71c91cb673a989a46a1dd8c7316fa9c27442744925dc629", + "reviewed_translation_sha256": "c57da7d241589b7d357c07f592be6ff5ed2115d0739e9517883bdffeb5fcbc0b" }, { "id": "planning-milestones-dg-adapters", @@ -98,8 +98,22 @@ "id": "planning-verification", "source": "docs/planning/verification.md", "translation": "docs/ko/planning/verification.md", - "reviewed_source_sha256": "03a647f9126c3cb12ef68e4732d11545982432d2d0f5f00b90cef00bc30f0109", - "reviewed_translation_sha256": "c2e70f5e5e385d04d0efb0e379b2659986717bee24a8f3229cba93db5c50d249" + "reviewed_source_sha256": "d6b8968a7fb99a83a476535b5d418b7fd55b51a72f5ccccf7ef104699a6f2968", + "reviewed_translation_sha256": "d0b5b9b36d40934010abd064337051115f69282b177eb1a8e20844de30e41eb4" + }, + { + "id": "operations", + "source": "docs/operations.md", + "translation": "docs/ko/operations.md", + "reviewed_source_sha256": "9de420cd6aeb80caab9246cff807f16f5f977f86b36398e23f98ca5ce3873f43", + "reviewed_translation_sha256": "9c2aea1e3bd2707ea2f235bd0fb1c407654056700c76c3241c97467d3794d6bb" + }, + { + "id": "contracts", + "source": "docs/contracts.md", + "translation": "docs/ko/contracts.md", + "reviewed_source_sha256": "319a68cc4f007974f3349c03ea954de5d5290cdc2aeb1fb6cd063aec32beb198", + "reviewed_translation_sha256": "0893f3d181f19631851753f4bac8dcaa431d7d26c3c2168a5e1f6fb44d71a722" } ] } diff --git a/milestones.json b/milestones.json index 5ba2584..dabc27e 100644 --- a/milestones.json +++ b/milestones.json @@ -6,7 +6,7 @@ "critical_path": ["DG-0", "DG-1", "CS-RG", "P1-RECOVERY"], "milestones": [ {"id": "DG-0", "owner": "DevGuard", "requires": [], "design_status": "accepted", "implementation_status": "implemented", "qualification": "report-required", "report_command": "python3 scripts/validate.py", "planning_document": "docs/planning/milestones/DG-0.md"}, - {"id": "DG-1", "owner": "DevGuard", "requires": ["DG-0"], "design_status": "accepted", "implementation_status": "not-started", "qualification": "not-run", "planning_document": "docs/planning/milestones/DG-1.md"}, + {"id": "DG-1", "owner": "DevGuard", "requires": ["DG-0"], "design_status": "accepted", "implementation_status": "in-progress", "qualification": "not-run", "planning_document": "docs/planning/milestones/DG-1.md"}, {"id": "CS-RG", "owner": "CodeSpace", "requires": ["DG-1"], "design_status": "accepted", "implementation_status": "not-started", "qualification": "not-run", "planning_document": "docs/planning/milestones/CS-RG.md"}, {"id": "P1-RECOVERY", "owner": "CodeSpace", "requires": ["CS-RG"], "design_status": "accepted", "implementation_status": "not-started", "qualification": "not-run", "planning_document": "docs/planning/milestones/P1-RECOVERY.md"}, {"id": "DG-LINUX", "owner": "DevGuard + CodeSpace", "requires": ["CS-RG"], "design_status": "accepted", "implementation_status": "not-started", "qualification": "not-run", "required_for_product_completion": true, "planning_document": "docs/planning/milestones/DG-LINUX.md"}, diff --git a/scripts/check_docs.py b/scripts/check_docs.py index 54b3277..0a23080 100644 --- a/scripts/check_docs.py +++ b/scripts/check_docs.py @@ -63,6 +63,7 @@ def registry(root): local_path(root, pair["translation"]) expected = {p.relative_to(root).as_posix() for p in (root / "docs/planning").rglob("*.md")} expected.add("docs/design.md") + expected.update({"docs/contracts.md", "docs/operations.md"}) if not expected <= sources: raise ValueError("unpaired authoritative documents: " + str(sorted(expected - sources))) return data @@ -75,7 +76,7 @@ def check_translations(root): if digest(root / pair[field]) != pair["reviewed_" + field + "_sha256"]: raise ValueError("unreviewed " + field + ": " + pair["id"]) source = (root / pair["source"]).read_text() - if re.search(r"[가-힣]", source): + if re.search(r"[가-힣]", source.replace("[한국어](", "[Korean](")): raise ValueError("authoritative prose must be English: " + pair["source"]) source_ids = re.findall(r"^### (" + WORK_ID + r")\b", source, re.M) translated_ids = re.findall(r"^### (" + WORK_ID + r")\b", diff --git a/scripts/qualify.py b/scripts/qualify.py new file mode 100644 index 0000000..abb614f --- /dev/null +++ b/scripts/qualify.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Run explicit nonempty functional suites; never infer SLO or unimplemented OS support.""" +import argparse +import datetime +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import time +import uuid + +from validate import PINNED_RUST, ROOT, source_contract, source_fingerprint + +SUITES = { + "dg1-authority": [ + ("storage", ["-p", "devguard-core", "--test", "authority_storage"]), + ("activation", ["-p", "devguard-core", "--test", "authority_contract", "storage_activation"]), + ("paths", ["-p", "devguard-daemon", "--lib", "paths::tests"]), + ("configuration", ["-p", "devguard-daemon", "--lib", "config::tests"]), + ("entrypoint", ["-p", "devguard-daemon", "--test", "entrypoint"]), + ], + "dg1-auth": [ + ("framing", ["-p", "devguard-client", "--lib"]), + ("credential-fd", ["-p", "devguard-client", "--test", "credentials"]), + ("native-peer", ["-p", "devguard-client", "--test", "native_peer"]), + ("wire-compatibility", ["-p", "devguard-client", "--test", "wire_compatibility"]), + ("service", ["-p", "devguard-daemon", "--lib", "server::tests"]), + ], +} + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument("suite",choices=sorted(SUITES)) + parser.add_argument("--offline",action="store_true") + parser.add_argument("--output",type=Path) + args=parser.parse_args() + run_id=datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")+"-"+uuid.uuid4().hex[:8] + output=(args.output or ROOT/"target/qualification"/(args.suite+"-"+run_id)).resolve() + if not output.is_relative_to(ROOT) or subprocess.run(["git","check-ignore","-q",str(output/"report.json")],cwd=ROOT).returncode: + parser.error("output must be a new ignored path inside this checkout") + output.mkdir(parents=True,exist_ok=False) + report={"schema":"devguard-functional-qualification/v1","suite":args.suite,"run_id":run_id, + "status":"failed","execution_mode":"bootstrap-functional-tests","self_governed":False, + "scope":("canonical configuration, ownership and storage" if args.suite == "dg1-authority" else "native UDS peer/credential transport and closed readiness") + "; no native resource-control or SLO qualification", + "runtime_qualification":{"macos_launch":"not_run","linux_cgroups":"not_run","foreground_slo":"not_run","candidate_self_use":"not_run"},"stages":[]} + environment=os.environ.copy() + environment.update(CARGO_BUILD_JOBS="1",RUST_TEST_THREADS="1") + try: + before=source_fingerprint();report["source"]=before + report["rustc"]=subprocess.check_output(["rustc","--version"],text=True,env=environment).strip() + if report["rustc"].split()[1]!=PINNED_RUST: raise RuntimeError("functional qualification requires Rust "+PINNED_RUST) + source_contract() + for name,selectors in SUITES[args.suite]: + command=["cargo","test","--locked",*(["--offline"] if args.offline else []),*selectors] + stage={"name":name,"command":command,"status":"failed","log":name+".log"} + report["stages"].append(stage);started=time.monotonic() + with (output/stage["log"]).open("w") as stream: + result=subprocess.run(command,cwd=ROOT,env=environment,stdout=stream,stderr=subprocess.STDOUT) + stage["seconds"]=round(time.monotonic()-started,3) + stage["tests_passed"]=sum(map(int,re.findall(r"test result: ok\. (\d+) passed",(output/stage["log"]).read_text(errors="replace")))) + if result.returncode or not stage["tests_passed"]: raise RuntimeError("failed or empty suite: "+name) + stage["status"]="passed" + print(name+": passed ("+str(stage["tests_passed"])+")",flush=True) + if source_fingerprint()!=before: raise RuntimeError("source changed during qualification") + report["status"]="passed" + except (OSError,ValueError,RuntimeError,subprocess.CalledProcessError) as error: + report["error"]=str(error) + finally: + (output/"report.json").write_text(json.dumps(report,indent=2)+"\n") + print(json.dumps({"status":report["status"],"report":str(output/"report.json")})) + return 0 if report["status"]=="passed" else 1 + + +if __name__=="__main__": + sys.exit(main()) diff --git a/scripts/test_check_docs.py b/scripts/test_check_docs.py index 32d24b8..abec34b 100644 --- a/scripts/test_check_docs.py +++ b/scripts/test_check_docs.py @@ -43,6 +43,14 @@ def test_duplicate_pair_fails(self): with self.assertRaisesRegex(ValueError, "duplicate"): check_docs.check_translations(self.root) + def test_runtime_guide_pair_cannot_be_dropped_to_bypass_review(self): + path = self.root / "docs/translations.json" + data = json.loads(path.read_text()) + data["pairs"] = [p for p in data["pairs"] if p["id"] != "operations"] + path.write_text(json.dumps(data)) + with self.assertRaisesRegex(ValueError, "unpaired"): + check_docs.check_translations(self.root) + def test_broken_local_link_fails(self): with (self.root / "README.md").open("a") as stream: stream.write("\n[missing](docs/missing.md)\n") diff --git a/scripts/validate.py b/scripts/validate.py index 7df1b9c..56a2237 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -64,8 +64,19 @@ def dependency_boundary(offline, environment, output): if forbidden: raise RuntimeError("independent graph includes product dependencies: " + ", ".join(forbidden)) roots = {p["name"] for p in packages if p["id"] in metadata["workspace_members"]} - if roots != {"devguard-contract", "devguard-core"}: - raise RuntimeError("unexpected DG-0 workspace graph") + allowed = { + "devguard-contract": set(), + "devguard-core": {"devguard-contract"}, + "devguard-daemon": {"devguard-contract", "devguard-core", "devguard-client"}, + "devguard-client": {"devguard-contract"}, + } + if roots != set(allowed): + raise RuntimeError("unexpected workspace graph; update explicit boundaries with new crates") + for package in packages: + if package["name"] in allowed: + edges = {d["name"] for d in package["dependencies"]} & roots + if edges != allowed[package["name"]]: + raise RuntimeError("workspace dependency boundary changed: " + package["name"]) contract = next(p for p in packages if p["name"] == "devguard-contract") if {d["name"] for d in contract["dependencies"]} != {"serde", "serde_json", "sha2"}: raise RuntimeError("contract must remain independent of persistence and host adapters")