From 19e652d6cdfce0d2fd318167c80fbc51dda9c2d5 Mon Sep 17 00:00:00 2001 From: Quant-TheodoreFelix <53819958+Quant-TheodoreFelix@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:48:38 +0900 Subject: [PATCH 1/6] =?UTF-8?q?airlock-i18n=20=ED=81=AC=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `Locale` 과 `tr!` 매크로로 한국어와 영문 메시지 중 하나를 고름 - 결정 순서는 `AIRLOCK_LANG` 과 구성 파일 `locale` 키와 `LANG` 접두 감지 순이고 기본은 한국어 - 로케일을 정책 파일이 아니라 `~/.config/airlock/config.toml` 에 두어 표시 언어가 정책 다이제스트를 바꾸지 않음 - 브로커와 정책 엔진의 의존성 트리에 들어가므로 의존을 하나도 두지 않고 최소 스캐너를 직접 둠 Co-authored-by: Claude Fable 5 --- Cargo.lock | 12 + Cargo.toml | 1 + crates/airlock-i18n/Cargo.toml | 15 ++ crates/airlock-i18n/src/lib.rs | 395 +++++++++++++++++++++++++++++++++ 4 files changed, 423 insertions(+) create mode 100644 crates/airlock-i18n/Cargo.toml create mode 100644 crates/airlock-i18n/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 9538f6b..933f705 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,6 +9,7 @@ dependencies = [ "airlock-audit", "airlock-broker", "airlock-canonical", + "airlock-i18n", "airlock-policy", "airlock-proxy", "airlock-setup", @@ -21,6 +22,7 @@ name = "airlock-audit" version = "0.1.0" dependencies = [ "airlock-canonical", + "airlock-i18n", "libc", "serde", "serde_json", @@ -33,6 +35,7 @@ version = "0.1.0" dependencies = [ "airlock-audit", "airlock-canonical", + "airlock-i18n", "airlock-policy", "airlock-proxy", "landlock", @@ -44,12 +47,20 @@ dependencies = [ [[package]] name = "airlock-canonical" version = "0.1.0" +dependencies = [ + "airlock-i18n", +] + +[[package]] +name = "airlock-i18n" +version = "0.1.0" [[package]] name = "airlock-policy" version = "0.1.0" dependencies = [ "airlock-canonical", + "airlock-i18n", "libc", "serde", "sha2", @@ -65,6 +76,7 @@ version = "0.1.0" name = "airlock-setup" version = "0.1.0" dependencies = [ + "airlock-i18n", "airlock-policy", "cliclack", "console", diff --git a/Cargo.toml b/Cargo.toml index 897c727..b8a4860 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ repository = "https://github.com/Quant-Off/airlock" airlock-audit = { path = "crates/airlock-audit", version = "0.1.0" } airlock-broker = { path = "crates/airlock-broker", version = "0.1.0" } airlock-canonical = { path = "crates/airlock-canonical", version = "0.1.0" } +airlock-i18n = { path = "crates/airlock-i18n", version = "0.1.0" } airlock-policy = { path = "crates/airlock-policy", version = "0.1.0" } airlock-proxy = { path = "crates/airlock-proxy", version = "0.1.0" } airlock-setup = { path = "crates/airlock-setup", version = "0.1.0" } diff --git a/crates/airlock-i18n/Cargo.toml b/crates/airlock-i18n/Cargo.toml new file mode 100644 index 0000000..ad5ad51 --- /dev/null +++ b/crates/airlock-i18n/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "airlock-i18n" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true +description = "airlock 출력 로케일 결정과 메시지 선택" + +# 의존이 없습니다. 이 크레이트는 브로커와 정책 엔진의 의존성 트리에 들어가므로 +# UI 의존성이나 파서를 끌어오면 TCB 경계가 넓어집니다 (docs/setup-wizard.md) +[dependencies] + +[lints] +workspace = true diff --git a/crates/airlock-i18n/src/lib.rs b/crates/airlock-i18n/src/lib.rs new file mode 100644 index 0000000..0c4ec14 --- /dev/null +++ b/crates/airlock-i18n/src/lib.rs @@ -0,0 +1,395 @@ +//! 이 모듈은 airlock 출력의 로케일을 결정하고 메시지 쌍에서 하나를 고릅니다. +//! +//! # Features +//! 로케일은 표시 계층만 바꿉니다. 감사 로그의 기계 판독 필드(kind, status, verdict)와 +//! 정책 다이제스트는 로케일과 무관하게 고정입니다. 그래서 로케일은 정책 파일이 아니라 +//! 별도 구성 파일(`~/.config/airlock/config.toml`)에 둡니다. 정책 다이제스트가 표시 +//! 언어 때문에 달라지면 감사 로그에 소음이 생깁니다. +//! +//! 결정 순서는 `AIRLOCK_LANG` 환경 변수, 구성 파일의 `locale` 키, +//! `LC_ALL`/`LC_MESSAGES`/`LANG` 접두 감지, 기본값 한국어입니다. 값을 읽지 못하면 +//! 조용히 다음 순서로 내려갑니다. 로케일은 표시 전용이라 실패가 보안 판정을 바꾸지 +//! 않습니다. +//! +//! # Examples +//! ```rust,ignore +//! airlock_i18n::init(); +//! let msg = airlock_i18n::tr!("정책 검증 통과", "policy verified"); +//! ``` + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU8, Ordering}; + +/// 출력 언어. +/// +/// 기본값은 한국어입니다. 이 프로젝트의 원문 언어이며, 어떤 환경 신호도 없을 때 +/// 기존 동작이 그대로 유지되어야 합니다 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Locale { + #[default] + Ko, + En, +} + +impl Locale { + pub fn as_str(self) -> &'static str { + match self { + Self::Ko => "ko", + Self::En => "en", + } + } + + /// 로케일 문자열을 읽습니다. + /// + /// `ko`, `en` 과 `ko_KR.UTF-8` 같은 POSIX 로케일 표기의 접두를 받습니다. 모르는 + /// 값은 `None` 이며 호출부가 다음 결정 순서로 내려갑니다 + /// + /// # Arguments + /// `s` - 로케일 문자열 + pub fn parse(s: &str) -> Option { + let t = s.trim().to_ascii_lowercase(); + let prefix = t.split(['_', '-', '.', '@']).next().unwrap_or_default(); + match prefix { + "ko" => Some(Self::Ko), + "en" => Some(Self::En), + _ => None, + } + } +} + +static LOCALE: AtomicU8 = AtomicU8::new(0); + +pub fn locale() -> Locale { + if LOCALE.load(Ordering::Relaxed) == 1 { + Locale::En + } else { + Locale::Ko + } +} + +pub fn set_locale(l: Locale) { + let v = match l { + Locale::Ko => 0, + Locale::En => 1, + }; + LOCALE.store(v, Ordering::Relaxed); +} + +/// 두 언어 메시지에서 현재 로케일의 것을 고릅니다. +/// +/// 첫 인자가 한국어, 둘째가 영문입니다. 포맷이 필요하면 각 인자를 `format!` 으로 +/// 감쌉니다. 선택된 쪽만 평가됩니다 +/// +/// # Examples +/// ```rust,ignore +/// tr!("정책 검증 통과", "policy verified"); +/// tr!(format!("{n}개 세션", n = 3), format!("{n} sessions", n = 3)); +/// ``` +#[macro_export] +macro_rules! tr { + ($ko:expr, $en:expr $(,)?) => { + match $crate::locale() { + $crate::Locale::Ko => $ko, + $crate::Locale::En => $en, + } + }; +} + +/// 로케일 구성 파일 경로. +/// +/// `XDG_CONFIG_HOME` 이 절대 경로면 그 아래, 아니면 `~/.config/airlock/config.toml` +/// 입니다. `HOME` 이 없거나 절대 경로가 아니면 `None` 입니다. 상대 경로 홈을 받으면 +/// 실행 위치에 따라 다른 파일을 읽게 되므로 받지 않습니다 +pub fn config_path() -> Option { + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + let p = PathBuf::from(xdg); + if p.is_absolute() { + return Some(p.join("airlock/config.toml")); + } + } + let home = PathBuf::from(std::env::var_os("HOME")?); + if !home.is_absolute() { + return None; + } + Some(home.join(".config/airlock/config.toml")) +} + +/// 환경과 구성 텍스트만으로 로케일을 정합니다. +/// +/// 파일과 전역 상태를 건드리지 않아 테스트에서 결정 순서를 고정할 수 있습니다 +/// +/// # Arguments +/// `env` - 환경 변수 조회 +/// `config` - 구성 파일 내용. 없으면 `None` +pub fn resolve(env: impl Fn(&str) -> Option, config: Option<&str>) -> Locale { + if let Some(l) = env("AIRLOCK_LANG").as_deref().and_then(Locale::parse) { + return l; + } + if let Some(l) = config.and_then(locale_from_config) { + return l; + } + for key in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Some(l) = env(key).as_deref().and_then(Locale::parse) { + return l; + } + } + Locale::default() +} + +/// 구성 텍스트의 최상위 `locale` 키를 읽습니다. +/// +/// TOML 파서를 쓰지 않습니다. 이 크레이트는 브로커와 정책 엔진의 의존성 트리에 +/// 들어가므로 의존을 하나도 두지 않습니다 (`docs/setup-wizard.md` 의 UI 의존성 경계). +/// 읽는 것이 짧은 문자열 하나뿐이라 최소 스캐너로 충분합니다. +/// +/// 첫 테이블 머리에서 멈춥니다. 테이블 안의 `locale` 은 최상위 키가 아니므로 읽으면 +/// 안 됩니다. 따옴표 없는 값은 받지 않습니다. 모르는 것을 추측하는 것보다 다음 결정 +/// 순서로 내려가는 쪽이 낫습니다 +/// +/// # Arguments +/// `text` - 구성 파일 내용 +fn locale_from_config(text: &str) -> Option { + for line in text.lines() { + let t = line.trim_start(); + if t.starts_with('[') { + break; + } + if let Some(raw) = t.strip_prefix("locale") { + let rest = raw.trim_start(); + if let Some(value) = rest.strip_prefix('=') { + return quoted(value).and_then(Locale::parse); + } + } + } + None +} + +/// 큰따옴표로 감싼 값의 알맹이. +/// +/// # Arguments +/// `raw` - `=` 뒤의 나머지 +fn quoted(raw: &str) -> Option<&str> { + let rest = raw.trim_start().strip_prefix('"')?; + let end = rest.find('"')?; + rest.get(..end) +} + +/// 구성 텍스트에서 `locale` 줄만 바꾸거나 새로 넣습니다. +/// +/// 다른 줄은 한 바이트도 건드리지 않습니다. 전체를 파싱해 다시 쓰면 사람이 적어 둔 +/// 주석과 배치가 바뀌고, 파서가 이해하지 못하는 파일을 통째로 잃습니다. +/// +/// 최상위 `locale` 줄이 없으면 첫 테이블 머리 **앞**에 넣습니다. 끝에 붙이면 그 키가 +/// 마지막 테이블 안으로 들어가 최상위 키가 아니게 됩니다 +/// +/// # Arguments +/// `text` - 기존 구성 파일 내용. 파일이 없으면 빈 문자열 +/// `l` - 저장할 로케일 +fn rewrite_locale(text: &str, l: Locale) -> String { + let entry = format!("locale = \"{}\"", l.as_str()); + let mut lines: Vec = text.lines().map(str::to_string).collect(); + + let mut table_at = None; + for (i, line) in lines.iter().enumerate() { + let t = line.trim_start(); + if t.starts_with('[') { + table_at = Some(i); + break; + } + if let Some(raw) = t.strip_prefix("locale") + && raw.trim_start().starts_with('=') + { + lines[i] = entry; + return join(&lines); + } + } + + match table_at { + Some(i) => lines.insert(i, entry), + None => lines.push(entry), + } + join(&lines) +} + +fn join(lines: &[String]) -> String { + let mut out = lines.join("\n"); + out.push('\n'); + out +} + +/// 실제 환경에서 로케일을 정합니다. +pub fn detect() -> Locale { + let config = config_path().and_then(|p| std::fs::read_to_string(p).ok()); + resolve( + |key| std::env::var(key).ok().filter(|v| !v.is_empty()), + config.as_deref(), + ) +} + +/// 프로세스 시작 시 한 번 부릅니다. 이후 출력이 감지된 로케일을 따릅니다 +pub fn init() { + set_locale(detect()); +} + +/// 로케일을 구성 파일에 남깁니다. +/// +/// `locale` 줄 하나만 바꾸거나 넣고 나머지 줄은 그대로 둡니다. 주석과 다른 키가 보존되며, +/// 이 크레이트가 이해하지 못하는 내용이 있어도 잃지 않습니다 +/// +/// # Arguments +/// `l` - 저장할 로케일 +/// +/// # Errors +/// 홈을 알 수 없거나 읽기와 쓰기가 실패하면 그 사유를 냅니다 +pub fn save_locale(l: Locale) -> std::io::Result { + let path = config_path().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + tr!( + "HOME이 비어 있거나 절대 경로가 아니라 구성 파일 위치를 정할 수 없음", + "cannot locate the config file because HOME is empty or not absolute" + ), + ) + })?; + let existing = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(e), + }; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, rewrite_locale(&existing, l))?; + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |key| { + pairs + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| (*v).to_string()) + } + } + + #[test] + fn parse_accepts_posix_locale_forms() { + assert_eq!(Locale::parse("ko"), Some(Locale::Ko)); + assert_eq!(Locale::parse("en"), Some(Locale::En)); + assert_eq!(Locale::parse("ko_KR.UTF-8"), Some(Locale::Ko)); + assert_eq!(Locale::parse("en_US.UTF-8"), Some(Locale::En)); + assert_eq!(Locale::parse("en-GB"), Some(Locale::En)); + assert_eq!(Locale::parse("EN"), Some(Locale::En)); + } + + #[test] + fn unknown_locales_are_refused_not_guessed() { + for bad in ["", "C", "POSIX", "fr_FR.UTF-8", "kor", "eng", "korean"] { + assert_eq!(Locale::parse(bad), None, "{bad}"); + } + } + + #[test] + fn airlock_lang_wins_over_everything() { + let l = resolve( + env_of(&[("AIRLOCK_LANG", "en"), ("LANG", "ko_KR.UTF-8")]), + Some("locale = \"ko\"\n"), + ); + assert_eq!(l, Locale::En); + } + + #[test] + fn config_wins_over_system_lang() { + let l = resolve( + env_of(&[("LANG", "ko_KR.UTF-8")]), + Some("locale = \"en\"\n"), + ); + assert_eq!(l, Locale::En); + } + + #[test] + fn system_lang_is_the_fallback() { + assert_eq!( + resolve(env_of(&[("LANG", "en_US.UTF-8")]), None), + Locale::En + ); + assert_eq!( + resolve( + env_of(&[("LC_ALL", "en_US.UTF-8"), ("LANG", "ko_KR.UTF-8")]), + None + ), + Locale::En + ); + } + + #[test] + fn the_default_is_korean() { + assert_eq!(resolve(env_of(&[]), None), Locale::Ko); + assert_eq!(resolve(env_of(&[("LANG", "C")]), None), Locale::Ko); + } + + #[test] + fn invalid_override_falls_through() { + let l = resolve( + env_of(&[("AIRLOCK_LANG", "fr"), ("LANG", "en_US.UTF-8")]), + Some("locale = \"broken"), + ); + assert_eq!(l, Locale::En); + } + + #[test] + fn a_locale_inside_a_table_is_not_a_top_level_key() { + assert_eq!(locale_from_config("[other]\nlocale = \"en\"\n"), None); + assert_eq!( + locale_from_config("locale = \"en\"\n[other]\nlocale = \"ko\"\n"), + Some(Locale::En) + ); + } + + #[test] + fn rewriting_replaces_only_the_locale_line() { + let before = "# 사람이 적은 주석\nlocale = \"ko\"\nfuture_key = 1\n"; + let after = rewrite_locale(before, Locale::En); + assert_eq!( + after, + "# 사람이 적은 주석\nlocale = \"en\"\nfuture_key = 1\n" + ); + } + + #[test] + fn rewriting_lands_before_the_first_table() { + let after = rewrite_locale("future_key = 1\n\n[other]\nkey = 2\n", Locale::En); + assert_eq!( + after, + "future_key = 1\n\nlocale = \"en\"\n[other]\nkey = 2\n" + ); + assert_eq!(locale_from_config(&after), Some(Locale::En)); + } + + #[test] + fn rewriting_an_absent_file_makes_a_single_line() { + assert_eq!(rewrite_locale("", Locale::Ko), "locale = \"ko\"\n"); + } + + #[test] + fn rewriting_keeps_content_it_does_not_understand() { + let before = "not toml [\nlocale = \"ko\"\n"; + let after = rewrite_locale(before, Locale::En); + assert!(after.contains("not toml ["), "{after}"); + assert!(after.contains("locale = \"en\""), "{after}"); + } + + #[test] + fn config_with_other_keys_still_yields_locale() { + assert_eq!( + locale_from_config("# 주석\nfuture_key = 1\nlocale = \"en\"\n"), + Some(Locale::En) + ); + assert_eq!(locale_from_config("locale = 3\n"), None); + assert_eq!(locale_from_config("not toml ["), None); + } +} From 95e147a34d9800d6b8946dbd3af28ba7fa6fbcb5 Mon Sep 17 00:00:00 2001 From: Quant-TheodoreFelix <53819958+Quant-TheodoreFelix@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:48:44 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=EB=B8=8C=EB=A1=9C=EC=BB=A4=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=EC=9D=84=20=EB=A1=9C=EC=BC=80=EC=9D=BC=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 승인 프롬프트와 강제 층 한계 목록과 오류 메시지를 두 언어로 냄 - 생성되는 Seatbelt 프로파일 주석도 로케일을 따름 - 규칙 id 와 중계 수준 이름 같은 기계 판독 값은 로케일과 무관하게 고정 Co-authored-by: Claude Fable 5 --- crates/airlock-broker/Cargo.toml | 1 + crates/airlock-broker/src/approve.rs | 72 ++++-- crates/airlock-broker/src/egress.rs | 23 +- crates/airlock-broker/src/enforcer.rs | 19 +- crates/airlock-broker/src/error.rs | 86 ++++++- crates/airlock-broker/src/landlock.rs | 354 +++++++++++++++++++------- crates/airlock-broker/src/notify.rs | 55 +++- crates/airlock-broker/src/profile.rs | 213 ++++++++++++---- crates/airlock-broker/src/seatbelt.rs | 105 +++++--- crates/airlock-broker/src/session.rs | 123 ++++++--- 10 files changed, 802 insertions(+), 249 deletions(-) diff --git a/crates/airlock-broker/Cargo.toml b/crates/airlock-broker/Cargo.toml index ba0c5f0..cb825d2 100644 --- a/crates/airlock-broker/Cargo.toml +++ b/crates/airlock-broker/Cargo.toml @@ -10,6 +10,7 @@ description = "Airlock의 OS 강제 층. Linux Landlock과 seccomp, macOS Seatbe [dependencies] airlock-audit.workspace = true airlock-canonical.workspace = true +airlock-i18n.workspace = true airlock-policy.workspace = true airlock-proxy.workspace = true libc.workspace = true diff --git a/crates/airlock-broker/src/approve.rs b/crates/airlock-broker/src/approve.rs index 5bb2215..8ffb724 100644 --- a/crates/airlock-broker/src/approve.rs +++ b/crates/airlock-broker/src/approve.rs @@ -17,6 +17,7 @@ use std::time::{Duration, Instant}; use airlock_audit::Granted; use airlock_canonical::display::sanitize; +use airlock_i18n::tr; use airlock_policy::MatchedRule; /// 사람 응답을 기다리는 기본 상한. @@ -95,9 +96,16 @@ impl Approver for RefuseAll { fn describe(&self) -> String { if self.why.is_empty() { - "비대화형 (모든 ask 거부)".to_string() + tr!( + "비대화형 (모든 ask 거부)", + "non-interactive (refuses every ask)" + ) + .to_string() } else { - format!("비대화형 (모든 ask 거부): {}", self.why) + tr!( + format!("비대화형 (모든 ask 거부): {}", self.why), + format!("non-interactive (refuses every ask): {}", self.why) + ) } } } @@ -111,11 +119,21 @@ impl Approver for ApproveAll { } fn describe(&self) -> String { - "자동 승인 (사람 판단 없음)".to_string() + tr!( + "자동 승인 (사람 판단 없음)", + "automatic approval (no human judgment)" + ) + .to_string() } fn note(&self) -> Option { - Some("자동 승인. 사람이 검토하지 않음".to_string()) + Some( + tr!( + "자동 승인. 사람이 검토하지 않음", + "automatic approval; not reviewed by a human" + ) + .to_string(), + ) } } @@ -191,7 +209,10 @@ fn wait_readable(fd: RawFd, timeout: Duration) -> bool { fn render(request: &ApprovalRequest) -> String { let mut out = String::new(); - out.push_str("\n\x1b[1;33m┌─ airlock 승인 요청 ─────────────────────────────\x1b[0m\n"); + out.push_str(tr!( + "\n\x1b[1;33m┌─ airlock 승인 요청 ─────────────────────────────\x1b[0m\n", + "\n\x1b[1;33m┌─ airlock approval request ──────────────────────\x1b[0m\n" + )); out.push_str(&format!( "\x1b[1;33m│\x1b[0m {}\n", sanitize(&request.headline) @@ -212,27 +233,30 @@ fn render(request: &ApprovalRequest) -> String { )); } if let Some(rule) = &request.rule { - let pad = " ".repeat(width.saturating_sub(2)); + let label = tr!("규칙", "rule"); + let pad = " ".repeat(width.saturating_sub(label.chars().count())); out.push_str(&format!( - "\x1b[1;33m│\x1b[0m 규칙{pad} {} ({} tier, {})\n", + "\x1b[1;33m│\x1b[0m {label}{pad} {} ({} tier, {})\n", sanitize(&rule.id), rule.tier, sanitize(&rule.pattern) )); if let Some(reason) = &rule.reason { - let pad = " ".repeat(width.saturating_sub(2)); + let label = tr!("근거", "reason"); + let pad = " ".repeat(width.saturating_sub(label.chars().count())); out.push_str(&format!( - "\x1b[1;33m│\x1b[0m 근거{pad} {}\n", + "\x1b[1;33m│\x1b[0m {label}{pad} {}\n", sanitize(reason) )); } } out.push_str("\x1b[1;33m│\x1b[0m\n"); - out.push_str( + out.push_str(tr!( "\x1b[1;33m│\x1b[0m \x1b[2m위 내용은 브로커가 직접 관측한 사실이며 에이전트가 제공한 설명이 아님\x1b[0m\n", - ); + "\x1b[1;33m│\x1b[0m \x1b[2mthe content above is what the broker directly observed, not a description provided by the agent\x1b[0m\n", + )); out.push_str("\x1b[1;33m└─────────────────────────────────────────────────\x1b[0m\n"); - out.push_str("허용하겠습니까? [y/N] "); + out.push_str(tr!("허용하겠습니까? [y/N] ", "allow? [y/N] ")); out } @@ -284,7 +308,13 @@ impl Approver for TtyApprover { return Granted::Refused; }; if !wait_readable(read_side.as_raw_fd(), self.timeout) { - let _ = tty.write_all("\x1b[31m시간 초과 거부\x1b[0m\n\n".as_bytes()); + let _ = tty.write_all( + tr!( + "\x1b[31m시간 초과 거부\x1b[0m\n\n", + "\x1b[31mrefused on timeout\x1b[0m\n\n" + ) + .as_bytes(), + ); return Granted::TimedOut; } let mut reader = BufReader::new(read_side); @@ -296,9 +326,9 @@ impl Approver for TtyApprover { let granted = matches!(answer.as_str(), "y" | "yes"); let _ = tty.write_all( if granted { - "\x1b[32m허용\x1b[0m\n\n" + tr!("\x1b[32m허용\x1b[0m\n\n", "\x1b[32mallowed\x1b[0m\n\n") } else { - "\x1b[31m거부\x1b[0m\n\n" + tr!("\x1b[31m거부\x1b[0m\n\n", "\x1b[31mrefused\x1b[0m\n\n") } .as_bytes(), ); @@ -313,9 +343,15 @@ impl Approver for TtyApprover { } fn describe(&self) -> String { - format!( - "/dev/tty 인라인 프롬프트 (응답 상한 {}초, 초과 시 거부)", - self.timeout.as_secs() + tr!( + format!( + "/dev/tty 인라인 프롬프트 (응답 상한 {}초, 초과 시 거부)", + self.timeout.as_secs() + ), + format!( + "/dev/tty inline prompt (response limit {}s, refused past that)", + self.timeout.as_secs() + ) ) } diff --git a/crates/airlock-broker/src/egress.rs b/crates/airlock-broker/src/egress.rs index dfeb93a..5f34aba 100644 --- a/crates/airlock-broker/src/egress.rs +++ b/crates/airlock-broker/src/egress.rs @@ -9,6 +9,7 @@ use std::sync::{Arc, Mutex}; use airlock_audit::Protocol as AuditProtocol; +use airlock_i18n::tr; use airlock_proxy::{Decision, EgressGate, Protocol}; use crate::session::{Actor, Session}; @@ -71,7 +72,16 @@ impl EgressGate for SessionGate { ) { let Ok(mut session) = self.session.lock() else { eprintln!( - "airlock: 경고 세션 잠금이 오염되어 {host}:{port} 의 반출 {bytes_out} 바이트를 기록하지 못함" + "{}", + tr!( + format!( + "airlock: 경고 세션 잠금이 오염되어 {host}:{port} 의 반출 {bytes_out} 바이트를 기록하지 못함" + ), + format!( + "airlock: warning: the session lock is poisoned; failed to record \ + {bytes_out} outbound bytes for {host}:{port}" + ) + ) ); return; }; @@ -85,7 +95,16 @@ impl EgressGate for SessionGate { Actor::Unknown, ) { eprintln!( - "airlock: 경고 {host}:{port} 의 반출 {bytes_out} 바이트를 감사에 남기지 못함: {e}" + "{}", + tr!( + format!( + "airlock: 경고 {host}:{port} 의 반출 {bytes_out} 바이트를 감사에 남기지 못함: {e}" + ), + format!( + "airlock: warning: failed to record {bytes_out} outbound bytes for \ + {host}:{port} in the audit log: {e}" + ) + ) ); } } diff --git a/crates/airlock-broker/src/enforcer.rs b/crates/airlock-broker/src/enforcer.rs index ad0c361..62d245b 100644 --- a/crates/airlock-broker/src/enforcer.rs +++ b/crates/airlock-broker/src/enforcer.rs @@ -3,6 +3,7 @@ use std::path::Path; use std::process::Command; use airlock_audit::Enforcement; +use airlock_i18n::tr; use airlock_policy::Policy; use crate::error::Result; @@ -44,7 +45,11 @@ impl Enforcer for ObserveEnforcer { } fn describe(&self) -> String { - "observe (강제 없음, 기록만)".to_string() + tr!( + "observe (강제 없음, 기록만)", + "observe (no enforcement, record only)" + ) + .to_string() } fn prepare(&mut self, _policy: &Policy) -> Result<()> { @@ -57,8 +62,16 @@ impl Enforcer for ObserveEnforcer { fn gaps(&self) -> Vec { vec![ - "커널 강제 없음, 정책 위반이 기록되지만 차단되지 않음".to_string(), - "브로커를 경유하지 않는 직접 파일 접근은 관측되지 않음".to_string(), + tr!( + "커널 강제 없음, 정책 위반이 기록되지만 차단되지 않음", + "no kernel enforcement; policy violations are recorded but not blocked" + ) + .to_string(), + tr!( + "브로커를 경유하지 않는 직접 파일 접근은 관측되지 않음", + "direct file access that does not pass through the broker is not observed" + ) + .to_string(), ] } } diff --git a/crates/airlock-broker/src/error.rs b/crates/airlock-broker/src/error.rs index bfee8ab..b1ee8a3 100644 --- a/crates/airlock-broker/src/error.rs +++ b/crates/airlock-broker/src/error.rs @@ -1,6 +1,8 @@ use std::fmt; use std::path::PathBuf; +use airlock_i18n::tr; + #[derive(Debug)] pub enum BrokerError { Audit(airlock_audit::Error), @@ -32,14 +34,42 @@ pub enum BrokerError { impl fmt::Display for BrokerError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Audit(e) => write!(f, "감사 로그 실패: {e}"), - Self::Policy(e) => write!(f, "정책 로드 실패: {e}"), + Self::Audit(e) => write!( + f, + "{}", + tr!( + format!("감사 로그 실패: {e}"), + format!("audit log failure: {e}") + ) + ), + Self::Policy(e) => write!( + f, + "{}", + tr!( + format!("정책 로드 실패: {e}"), + format!("policy load failure: {e}") + ) + ), Self::Io { what, source } => write!(f, "{what}: {source}"), - Self::ProgramNotFound(p) => write!(f, "실행할 프로그램을 찾을 수 없음: {p}"), + Self::ProgramNotFound(p) => write!( + f, + "{}", + tr!( + format!("실행할 프로그램을 찾을 수 없음: {p}"), + format!("cannot find the program to run: {p}") + ) + ), Self::Blocked { what, rule, reason } => { - write!(f, "{what}이(가) 정책에 의해 차단됨")?; + write!( + f, + "{}", + tr!( + format!("{what}이(가) 정책에 의해 차단됨"), + format!("{what} was blocked by policy") + ) + )?; if let Some(r) = rule { - write!(f, " (규칙 {r})")?; + write!(f, "{}", tr!(format!(" (규칙 {r})"), format!(" (rule {r})")))?; } if let Some(r) = reason { write!(f, ": {r}")?; @@ -47,22 +77,54 @@ impl fmt::Display for BrokerError { Ok(()) } Self::EnforcerUnavailable { name, why } => { - write!(f, "{name} 강제 백엔드를 쓸 수 없음: {why}") + write!( + f, + "{}", + tr!( + format!("{name} 강제 백엔드를 쓸 수 없음: {why}"), + format!("the {name} enforcement backend is unavailable: {why}") + ) + ) } Self::ProfileNotRepresentable(items) => write!( f, - "강제 프로파일로 표현할 수 없는 규칙이 있음: {}", - items.join(", ") + "{}", + tr!( + format!( + "강제 프로파일로 표현할 수 없는 규칙이 있음: {}", + items.join(", ") + ), + format!( + "rules that cannot be expressed in the enforcement profile: {}", + items.join(", ") + ) + ) ), Self::NoControlTerminal => write!( f, - "/dev/tty를 열 수 없어 승인을 받을 수 없음. ask 결정은 거부로 처리됨" + "{}", + tr!( + "/dev/tty를 열 수 없어 승인을 받을 수 없음. ask 결정은 거부로 처리됨", + "cannot open /dev/tty to obtain approval; ask decisions are treated as refusals" + ) + ), + Self::InvalidPath(p) => write!( + f, + "{}", + tr!( + format!("경로를 다룰 수 없음: {}", p.display()), + format!("cannot handle the path: {}", p.display()) + ) ), - Self::InvalidPath(p) => write!(f, "경로를 다룰 수 없음: {}", p.display()), Self::SessionClosed => write!( f, - "세션이 이미 닫힘. session_end 뒤에 엔트리를 붙이면 앵커보다 체인이 길어져 \ - 종료 후 덧붙이기로 보고됨" + "{}", + tr!( + "세션이 이미 닫힘. session_end 뒤에 엔트리를 붙이면 앵커보다 체인이 길어져 \ + 종료 후 덧붙이기로 보고됨", + "the session is already closed; an entry appended after session_end makes \ + the chain longer than the anchor and is reported as appending after close" + ) ), } } diff --git a/crates/airlock-broker/src/landlock.rs b/crates/airlock-broker/src/landlock.rs index 88a2d50..ddca246 100644 --- a/crates/airlock-broker/src/landlock.rs +++ b/crates/airlock-broker/src/landlock.rs @@ -25,6 +25,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use airlock_audit::Enforcement; +use airlock_i18n::tr; use airlock_policy::glob::Pattern; use airlock_policy::rule::{Matcher, ProgramMatch}; use airlock_policy::{Action, FileMode, Policy}; @@ -568,22 +569,44 @@ fn build_plan(policy: &Policy, opts: &ProfileOptions) -> Plan { let exec_trees = plan_exec_allow(policy, opts, &mut walker, &mut plan); for dir in walker.exhausted.iter().take(5) { - plan.gaps.push(format!( - "{} 아래는 검사 예산({WALK_BUDGET} 항목)을 넘겨 허용하지 않았음. 필요하면 작업 공간을 좁혀야 함", - dir.display() + plan.gaps.push(tr!( + format!( + "{} 아래는 검사 예산({WALK_BUDGET} 항목)을 넘겨 허용하지 않았음. 필요하면 작업 공간을 좁혀야 함", + dir.display() + ), + format!( + "everything under {} was left ungranted because the walk budget \ + ({WALK_BUDGET} entries) was exceeded; narrow the workspace if needed", + dir.display() + ) )); } for dir in walker.unreadable.iter().take(5) { - plan.gaps.push(format!( - "{} 는 나열할 수 없어 하위를 검사하지 못했음. 통째로 허용하지 않았으므로 그 아래는 열리지 않음", - dir.display() + plan.gaps.push(tr!( + format!( + "{} 는 나열할 수 없어 하위를 검사하지 못했음. 통째로 허용하지 않았으므로 그 아래는 열리지 않음", + dir.display() + ), + format!( + "{} could not be listed, so its contents were not examined; it was not \ + granted wholesale, so nothing under it opens", + dir.display() + ) )); } if walker.links > 0 { - plan.gaps.push(format!( - "심볼릭 링크 {}개를 허용 계획에서 제외했음. 규칙이 inode 에 걸리므로 \ - 링크가 가리키는 대상이 따로 허용되지 않으면 링크로도 열리지 않음", - walker.links + plan.gaps.push(tr!( + format!( + "심볼릭 링크 {}개를 허용 계획에서 제외했음. 규칙이 inode 에 걸리므로 \ + 링크가 가리키는 대상이 따로 허용되지 않으면 링크로도 열리지 않음", + walker.links + ), + format!( + "{} symbolic links were left out of the grant plan; rules attach to \ + inodes, so unless a link's target is granted separately it does not \ + open through the link either", + walker.links + ) )); } @@ -629,39 +652,71 @@ fn exec_allow_targets(policy: &Policy, gaps: &mut Vec) -> Vec { Matcher::Exec { program, .. } => match program { Some(ProgramMatch::Path(pattern)) => match concrete_target(pattern) { Some(p) => out.push(p), - None => gaps.push(format!( - "exec allow 규칙 {} 의 경로 패턴 {} 은 inode 규칙으로 옮길 수 없어 \ - 실행 허용에 넣지 않았음. 이 규칙이 가리키는 프로그램은 커널이 거부함", - rule.id, - pattern.raw() + None => gaps.push(tr!( + format!( + "exec allow 규칙 {} 의 경로 패턴 {} 은 inode 규칙으로 옮길 수 없어 \ + 실행 허용에 넣지 않았음. 이 규칙이 가리키는 프로그램은 커널이 거부함", + rule.id, + pattern.raw() + ), + format!( + "exec allow rule {} has path pattern {} that cannot become an \ + inode rule, so it was left out of the exec grants; the kernel \ + refuses the program this rule points to", + rule.id, + pattern.raw() + ) )), }, Some(ProgramMatch::Basename(name)) => { let found = profile::resolve_in_path(name); if found.is_empty() { - gaps.push(format!( - "exec allow 규칙 {} 의 program = \"{name}\" 을 PATH 에서 찾지 \ - 못해 실행 허용에 넣지 않았음. 이 프로그램은 커널이 거부함", - rule.id + gaps.push(tr!( + format!( + "exec allow 규칙 {} 의 program = \"{name}\" 을 PATH 에서 찾지 \ + 못해 실행 허용에 넣지 않았음. 이 프로그램은 커널이 거부함", + rule.id + ), + format!( + "exec allow rule {} has program = \"{name}\" which was not \ + found in PATH, so it was left out of the exec grants; the \ + kernel refuses this program", + rule.id + ) )); } out.extend(found); } - None => gaps.push(format!( - "exec allow 규칙 {} 에 프로그램 조건이 없어 실행 대상을 특정할 수 없음. \ - 실행 허용에 넣지 않았음", - rule.id + None => gaps.push(tr!( + format!( + "exec allow 규칙 {} 에 프로그램 조건이 없어 실행 대상을 특정할 수 없음. \ + 실행 허용에 넣지 않았음", + rule.id + ), + format!( + "exec allow rule {} has no program condition, so the exec target \ + cannot be pinned down; it was left out of the exec grants", + rule.id + ) )), }, Matcher::File { paths, modes } if modes.contains(FileMode::Exec) => { for pattern in paths { match concrete_target(pattern) { Some(p) => out.push(p), - None => gaps.push(format!( - "규칙 {} 의 경로 {} 는 exec 모드를 허용하지만 inode 규칙으로 \ - 옮길 수 없어 실행 허용에 넣지 않았음", - rule.id, - pattern.raw() + None => gaps.push(tr!( + format!( + "규칙 {} 의 경로 {} 는 exec 모드를 허용하지만 inode 규칙으로 \ + 옮길 수 없어 실행 허용에 넣지 않았음", + rule.id, + pattern.raw() + ), + format!( + "rule {} allows exec mode on path {}, but it cannot become \ + an inode rule and was left out of the exec grants", + rule.id, + pattern.raw() + ) )), } } @@ -717,16 +772,27 @@ fn plan_exec_allow( .filter(|p| Path::new(p).exists()) .collect(); if !runtime.is_empty() { - plan.gaps.push(format!( - "동적 링커에 Execute 가 필요해 {} 를 실행 허용에 함께 넣었음. 그 아래의 실행 \ - 파일은 정책이 따로 허용하지 않아도 실행됨", - runtime.join(", ") + plan.gaps.push(tr!( + format!( + "동적 링커에 Execute 가 필요해 {} 를 실행 허용에 함께 넣었음. 그 아래의 실행 \ + 파일은 정책이 따로 허용하지 않아도 실행됨", + runtime.join(", ") + ), + format!( + "the dynamic linker needs Execute, so {} were added to the exec grants; \ + executables under them run even without a separate policy allow", + runtime.join(", ") + ) )); } plan.gaps.push( - "mmap(PROT_EXEC) 은 Landlock 이 매개하지 않음. 읽을 수 있는 파일을 실행 가능하게 \ - 매핑해 그 안으로 뛰는 경로는 화이트리스트 밖임" - .to_string(), + tr!( + "mmap(PROT_EXEC) 은 Landlock 이 매개하지 않음. 읽을 수 있는 파일을 실행 가능하게 \ + 매핑해 그 안으로 뛰는 경로는 화이트리스트 밖임", + "mmap(PROT_EXEC) is not mediated by Landlock; mapping a readable file as \ + executable and jumping into it is outside the whitelist" + ) + .to_string(), ); trees @@ -787,10 +853,18 @@ fn plan_exec_gap(policy: &Policy, trees: &[PathBuf], plan: &mut Plan) { } ids.sort_unstable(); ids.dedup(); - plan.gaps.push(format!( - "[defaults].exec = \"allow\" 라 exec 화이트리스트를 걸지 않았음. 읽을 수 있는 \ - 파일은 전부 실행할 수 있으며 아래 규칙은 중계 층이 관측할 뿐임: {}", - ids.join(", ") + plan.gaps.push(tr!( + format!( + "[defaults].exec = \"allow\" 라 exec 화이트리스트를 걸지 않았음. 읽을 수 있는 \ + 파일은 전부 실행할 수 있으며 아래 규칙은 중계 층이 관측할 뿐임: {}", + ids.join(", ") + ), + format!( + "[defaults].exec = \"allow\", so no exec whitelist was applied; every \ + readable file can be executed, and the rules below are only observed by \ + the mediation layer: {}", + ids.join(", ") + ) )); return; } @@ -798,10 +872,18 @@ fn plan_exec_gap(policy: &Policy, trees: &[PathBuf], plan: &mut Plan) { argv_ids.sort_unstable(); argv_ids.dedup(); if !argv_ids.is_empty() { - plan.gaps.push(format!( - "argv 조건은 커널이 볼 수 없어 프로그램 경로 단위로만 강제됨. 아래 규칙의 \ - argv 판정은 중계 층에만 있음: {}", - argv_ids.join(", ") + plan.gaps.push(tr!( + format!( + "argv 조건은 커널이 볼 수 없어 프로그램 경로 단위로만 강제됨. 아래 규칙의 \ + argv 판정은 중계 층에만 있음: {}", + argv_ids.join(", ") + ), + format!( + "argv conditions are invisible to the kernel, so enforcement is per \ + program path only; the argv decisions of these rules exist only in the \ + mediation layer: {}", + argv_ids.join(", ") + ) )); } @@ -819,11 +901,20 @@ fn plan_exec_gap(policy: &Policy, trees: &[PathBuf], plan: &mut Plan) { inside.sort_unstable(); inside.dedup(); let listed: Vec = trees.iter().map(|t| t.display().to_string()).collect(); - plan.gaps.push(format!( - "실행 허용이 트리로 걸린 곳({}) 안의 exec 제한 규칙은 커널에서 걸러지지 않음. \ - Landlock 은 허용한 트리에서 일부만 빼는 것을 표현할 수 없음: {}", - listed.join(", "), - inside.join(", ") + plan.gaps.push(tr!( + format!( + "실행 허용이 트리로 걸린 곳({}) 안의 exec 제한 규칙은 커널에서 걸러지지 않음. \ + Landlock 은 허용한 트리에서 일부만 빼는 것을 표현할 수 없음: {}", + listed.join(", "), + inside.join(", ") + ), + format!( + "exec restriction rules inside tree-granted exec roots ({}) are not filtered \ + by the kernel; Landlock cannot express carving a part out of a granted \ + tree: {}", + listed.join(", "), + inside.join(", ") + ) )); } @@ -869,9 +960,14 @@ fn plan_network(policy: &Policy, opts: &ProfileOptions, plan: &mut Plan) { // 연결하는 것을 막지 못하므로, 이 백엔드만으로는 프록시가 유일한 출구가 // 되지 않습니다. netns 로 자식의 경로 자체를 끊는 것이 다음 단계입니다 plan.gaps.push( - "egress 프록시가 켜졌으나 Landlock 은 포트까지만 강제함. 자식이 같은 포트로 \ - 다른 호스트에 직접 연결하면 프록시를 건너뜀. network namespace 격리가 필요함" - .to_string(), + tr!( + "egress 프록시가 켜졌으나 Landlock 은 포트까지만 강제함. 자식이 같은 포트로 \ + 다른 호스트에 직접 연결하면 프록시를 건너뜀. network namespace 격리가 필요함", + "the egress proxy is on, but Landlock only enforces down to the port; a \ + child connecting directly to another host on the same port bypasses the \ + proxy; network namespace isolation is needed" + ) + .to_string(), ); return; } @@ -925,33 +1021,60 @@ fn plan_network(policy: &Policy, opts: &ProfileOptions, plan: &mut Plan) { // 포트를 적지 않은 allow 가 하나라도 있으면 TCP 제한 자체를 걸지 않습니다. // 이때 "포트까지는 강제한다"고 알리면 사실과 정반대가 됩니다 plan.gaps.push( - "포트를 특정하지 않은 egress allow 규칙이 있어 아웃바운드를 통째로 열었음. \ - 포트 제한도 걸리지 않으므로 규칙마다 port 를 적어야 함" - .to_string(), + tr!( + "포트를 특정하지 않은 egress allow 규칙이 있어 아웃바운드를 통째로 열었음. \ + 포트 제한도 걸리지 않으므로 규칙마다 port 를 적어야 함", + "an egress allow rule without a specific port opened outbound entirely; \ + not even port limits apply, so every rule should state a port" + ) + .to_string(), ); } if !host_scoped.is_empty() && !plan.unrestricted_net { - plan.gaps.push(format!( - "호스트 단위 egress 규칙은 Landlock으로 강제되지 않음. 포트까지만 강제하며 \ - 호스트 판정은 프록시 층이 필요함: {}", - host_scoped.join(", ") + plan.gaps.push(tr!( + format!( + "호스트 단위 egress 규칙은 Landlock으로 강제되지 않음. 포트까지만 강제하며 \ + 호스트 판정은 프록시 층이 필요함: {}", + host_scoped.join(", ") + ), + format!( + "per-host egress rules are not enforced by Landlock; enforcement stops at \ + the port, and host decisions need the proxy layer: {}", + host_scoped.join(", ") + ) )); } if !protocol_scoped.is_empty() { - plan.gaps.push(format!( - "protocol 조건이 붙은 egress allow 규칙의 포트가 커널에서는 조건 없이 열림. \ - Landlock 은 프로토콜을 보지 못하므로 규칙보다 넓게 걸림: {}", - protocol_scoped.join(", ") + plan.gaps.push(tr!( + format!( + "protocol 조건이 붙은 egress allow 규칙의 포트가 커널에서는 조건 없이 열림. \ + Landlock 은 프로토콜을 보지 못하므로 규칙보다 넓게 걸림: {}", + protocol_scoped.join(", ") + ), + format!( + "ports from egress allow rules with a protocol condition open \ + unconditionally in the kernel; Landlock cannot see the protocol, so the \ + grant is wider than the rule: {}", + protocol_scoped.join(", ") + ) )); } if !quota_scoped.is_empty() { - plan.gaps.push(format!( - "max_bytes_out 은 커널이 강제하지 않음. 반출 바이트는 프록시 층만 세고, 한도를 \ - 넘긴 그 연결 자체는 막지 못하며 다음 연결부터 막힘: {}", - quota_scoped.join(", ") + plan.gaps.push(tr!( + format!( + "max_bytes_out 은 커널이 강제하지 않음. 반출 바이트는 프록시 층만 세고, 한도를 \ + 넘긴 그 연결 자체는 막지 못하며 다음 연결부터 막힘: {}", + quota_scoped.join(", ") + ), + format!( + "max_bytes_out is not kernel-enforced; only the proxy layer counts \ + outbound bytes, the connection that crosses the limit is not itself \ + blocked, and blocking starts from the next connection: {}", + quota_scoped.join(", ") + ) )); } } @@ -1069,10 +1192,13 @@ fn open_rule_target(target: &PlanPath) -> Option { /// `pre_exec` 문맥에서 부르므로 할당 없이 정적 바이트열만 fd 2로 씁니다. /// `write`는 async-signal-safe 하며 실패는 무시합니다 fn warn_partial() { - const MSG: &str = - "airlock: 경고 커널이 Landlock 규칙을 부분만 적용했음. 일부 접근 종류가 강제되지 않음\n"; + let msg: &'static str = tr!( + "airlock: 경고 커널이 Landlock 규칙을 부분만 적용했음. 일부 접근 종류가 강제되지 않음\n", + "airlock: warning: the kernel applied the Landlock rules only partially; some \ + access kinds are not enforced\n" + ); unsafe { - libc::write(2, MSG.as_ptr().cast(), MSG.len()); + libc::write(2, msg.as_ptr().cast(), msg.len()); } } @@ -1146,11 +1272,20 @@ impl Enforcer for LandlockEnforcer { fn describe(&self) -> String { match self.abi { - ABI::Unsupported => "landlock (커널 미지원)".to_string(), - abi => format!( - "landlock (ABI v{}, 규칙 {}개)", - abi as u32, - self.rule_count() + ABI::Unsupported => { + tr!("landlock (커널 미지원)", "landlock (kernel unsupported)").to_string() + } + abi => tr!( + format!( + "landlock (ABI v{}, 규칙 {}개)", + abi as u32, + self.rule_count() + ), + format!( + "landlock (ABI v{}, {} rules)", + abi as u32, + self.rule_count() + ) ), } } @@ -1163,7 +1298,11 @@ impl Enforcer for LandlockEnforcer { if self.abi == ABI::Unsupported { return Err(BrokerError::EnforcerUnavailable { name: "landlock", - why: "커널이 Landlock을 지원하지 않음. Linux 5.13 이상이 필요함".to_string(), + why: tr!( + "커널이 Landlock을 지원하지 않음. Linux 5.13 이상이 필요함", + "the kernel does not support Landlock; Linux 5.13 or later is required" + ) + .to_string(), }); } let plan = build_plan(policy, &self.options); @@ -1176,7 +1315,11 @@ impl Enforcer for LandlockEnforcer { let Some(mut plan) = self.plan.clone() else { return Err(BrokerError::EnforcerUnavailable { name: "landlock", - why: "prepare가 먼저 호출되지 않았음".to_string(), + why: tr!( + "prepare가 먼저 호출되지 않았음", + "prepare was not called first" + ) + .to_string(), }); }; // 최상위 프로그램은 언제나 실행 허용에 들어가야 합니다. 빠지면 커널이 첫 @@ -1191,10 +1334,16 @@ impl Enforcer for LandlockEnforcer { if plan.exec_paths.is_empty() { return Err(BrokerError::EnforcerUnavailable { name: "landlock", - why: "[defaults].exec 이 allow 가 아니어서 exec 을 화이트리스트로 거는데 \ - 실행 허용 경로가 하나도 없음. 최상위 프로그램을 해소하지 못했거나 \ - 정책에 exec allow 규칙이 없음. 이대로 걸면 프로세스가 뜨지 못함" - .to_string(), + why: tr!( + "[defaults].exec 이 allow 가 아니어서 exec 을 화이트리스트로 거는데 \ + 실행 허용 경로가 하나도 없음. 최상위 프로그램을 해소하지 못했거나 \ + 정책에 exec allow 규칙이 없음. 이대로 걸면 프로세스가 뜨지 못함", + "[defaults].exec is not allow, so exec is whitelisted, but there is \ + not a single exec-granted path; the top-level program could not be \ + resolved, or the policy has no exec allow rules; applying this \ + would keep the process from starting" + ) + .to_string(), }); } } @@ -1210,9 +1359,10 @@ impl Enforcer for LandlockEnforcer { cmd.pre_exec(move || { let status = apply(&plan, abi)?; if status == RulesetStatus::NotEnforced { - return Err(std::io::Error::other( + return Err(std::io::Error::other(tr!( "Landlock 규칙이 적용되지 않았음. 강제 없이 실행하지 않음", - )); + "Landlock rules were not applied; refusing to run without enforcement" + ))); } if status != RulesetStatus::FullyEnforced { warn_partial(); @@ -1225,21 +1375,41 @@ impl Enforcer for LandlockEnforcer { fn gaps(&self) -> Vec { let mut gaps = vec![ - "ask 규칙은 커널에서 표현할 수 없으므로 Landlock 계획에서 deny로 내려감".to_string(), - "Landlock이 커널에서 거부한 접근 자체는 감사 로그에 남지 않음. \ - 체인에는 중계 층이 관측한 것만 기록됨" - .to_string(), + tr!( + "ask 규칙은 커널에서 표현할 수 없으므로 Landlock 계획에서 deny로 내려감", + "ask rules cannot be expressed in the kernel, so the Landlock plan \ + degrades them to deny" + ) + .to_string(), + tr!( + "Landlock이 커널에서 거부한 접근 자체는 감사 로그에 남지 않음. \ + 체인에는 중계 층이 관측한 것만 기록됨", + "accesses Landlock refused in the kernel are themselves absent from the \ + audit log; the chain records only what the mediation layer observed" + ) + .to_string(), ]; if self.abi < ABI::V4 { - gaps.push(format!( - "ABI v{}는 TCP 포트 규칙을 지원하지 않음(v4 필요). 아웃바운드가 강제되지 않음", - self.abi as u32 + gaps.push(tr!( + format!( + "ABI v{}는 TCP 포트 규칙을 지원하지 않음(v4 필요). 아웃바운드가 강제되지 않음", + self.abi as u32 + ), + format!( + "ABI v{} does not support TCP port rules (v4 required); outbound is \ + not enforced", + self.abi as u32 + ) )); } gaps.push( - "UDP는 Landlock ABI v10부터이며 크레이트가 아직 노출하지 않음. \ - UDP 아웃바운드는 강제되지 않음" - .to_string(), + tr!( + "UDP는 Landlock ABI v10부터이며 크레이트가 아직 노출하지 않음. \ + UDP 아웃바운드는 강제되지 않음", + "UDP arrives with Landlock ABI v10 and the crate does not expose it yet; \ + UDP outbound is not enforced" + ) + .to_string(), ); gaps.extend(self.gaps.iter().cloned()); gaps diff --git a/crates/airlock-broker/src/notify.rs b/crates/airlock-broker/src/notify.rs index 778a9dc..12c2e71 100644 --- a/crates/airlock-broker/src/notify.rs +++ b/crates/airlock-broker/src/notify.rs @@ -22,6 +22,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use airlock_audit::Protocol; +use airlock_i18n::tr; use airlock_policy::FileMode; use crate::session::{Actor, Session}; @@ -282,7 +283,12 @@ unsafe fn install_filter(prog: &[SockFilter]) -> std::io::Result { if fd < 0 { return Err(std::io::Error::last_os_error()); } - RawFd::try_from(fd).map_err(|_| std::io::Error::other("listener fd가 범위를 벗어남")) + RawFd::try_from(fd).map_err(|_| { + std::io::Error::other(tr!( + "listener fd가 범위를 벗어남", + "listener fd out of range" + )) + }) } /// SCM_RIGHTS로 fd 하나를 보냅니다. @@ -307,7 +313,10 @@ unsafe fn send_fd(sock: RawFd, fd: RawFd) -> std::io::Result<()> { unsafe { let cmsg = libc::CMSG_FIRSTHDR(&raw const msg); if cmsg.is_null() { - return Err(std::io::Error::other("cmsg 헤더를 만들 수 없음")); + return Err(std::io::Error::other(tr!( + "cmsg 헤더를 만들 수 없음", + "cannot build the cmsg header" + ))); } (*cmsg).cmsg_level = libc::SOL_SOCKET; (*cmsg).cmsg_type = libc::SCM_RIGHTS; @@ -349,12 +358,18 @@ unsafe fn recv_fd(sock: RawFd) -> std::io::Result { || (*cmsg).cmsg_level != libc::SOL_SOCKET || (*cmsg).cmsg_type != libc::SCM_RIGHTS { - return Err(std::io::Error::other("listener fd를 받지 못함")); + return Err(std::io::Error::other(tr!( + "listener fd를 받지 못함", + "did not receive the listener fd" + ))); } let mut fd: RawFd = -1; std::ptr::copy_nonoverlapping(libc::CMSG_DATA(cmsg).cast::(), &raw mut fd, 1); if fd < 0 { - return Err(std::io::Error::other("받은 fd가 유효하지 않음")); + return Err(std::io::Error::other(tr!( + "받은 fd가 유효하지 않음", + "the received fd is not valid" + ))); } Ok(OwnedFd::from_raw_fd(fd)) } @@ -683,8 +698,19 @@ const MAX_ARGV: usize = 256; /// /// 감사 로그는 "관측된 사실"을 주장하므로, 부분값을 전부인 것처럼 남길 수 없습니다. /// 정책 평가도 이 잘린 목록으로 이루어졌음을 사후에 알 수 있어야 합니다 -const ARGV_TRUNCATED: &str = "…airlock: argv가 상한에서 잘림"; -const ARGV_UNREADABLE: &str = "…airlock: argv 원소를 읽지 못해 여기서 멈춤"; +fn argv_truncated() -> &'static str { + tr!( + "…airlock: argv가 상한에서 잘림", + "…airlock: argv truncated at the limit" + ) +} + +fn argv_unreadable() -> &'static str { + tr!( + "…airlock: argv 원소를 읽지 못해 여기서 멈춤", + "…airlock: stopped here because an argv element could not be read" + ) +} fn read_argv(pid: u32, addr: u64) -> Vec { let mut out = Vec::new(); @@ -705,7 +731,7 @@ fn read_argv(pid: u32, addr: u64) -> Vec { // 포인터 배열을 한 칸씩 읽습니다. 실패하면 거기서 멈춥니다 let n = unsafe { libc::process_vm_readv(pid as libc::pid_t, &local, 1, &remote, 1, 0) }; if n != 8 { - out.push(ARGV_UNREADABLE.to_string()); + out.push(argv_unreadable().to_string()); break; } if slot == 0 { @@ -714,13 +740,13 @@ fn read_argv(pid: u32, addr: u64) -> Vec { match read_cstr(pid, slot) { Some(s) => out.push(s.to_string_lossy().into_owned()), None => { - out.push(ARGV_UNREADABLE.to_string()); + out.push(argv_unreadable().to_string()); break; } } } if out.len() >= MAX_ARGV { - out.push(ARGV_TRUNCATED.to_string()); + out.push(argv_truncated().to_string()); } out } @@ -736,11 +762,18 @@ pub struct NotifyChannel { impl NotifyChannel { pub fn new(level: Level) -> std::io::Result { if level == Level::Off { - return Err(std::io::Error::other("중계가 꺼져 있음")); + return Err(std::io::Error::other(tr!( + "중계가 꺼져 있음", + "mediation is off" + ))); } // 필터를 fork 전에 만들어 둡니다. 자식의 pre_exec 문맥에서는 새로 할당하지 않습니다 let prog = build_filter(level).ok_or_else(|| { - std::io::Error::other("이 아키텍처의 seccomp arch 값을 모름. 중계 필터를 만들 수 없음") + std::io::Error::other(tr!( + "이 아키텍처의 seccomp arch 값을 모름. 중계 필터를 만들 수 없음", + "the seccomp arch value for this architecture is unknown; cannot build \ + the mediation filter" + )) })?; let mut fds = [0 as RawFd; 2]; // # Safety diff --git a/crates/airlock-broker/src/profile.rs b/crates/airlock-broker/src/profile.rs index c4c71e5..47183e9 100644 --- a/crates/airlock-broker/src/profile.rs +++ b/crates/airlock-broker/src/profile.rs @@ -15,6 +15,7 @@ use std::ffi::OsStr; use std::path::{Path, PathBuf}; +use airlock_i18n::tr; use airlock_policy::rule::{Matcher, ProgramMatch}; use airlock_policy::{Action, FileMode, ModeSet, Policy}; @@ -223,16 +224,25 @@ pub fn generate(policy: &Policy, opts: &ProfileOptions) -> GeneratedProfile { let exec_whitelist = exec_whitelist_mode(policy); out.push_str("(version 1)\n"); - out.push_str(";; airlock 생성 프로파일. deny-default이며 마지막 규칙이 이김\n"); + out.push_str(tr!( + ";; airlock 생성 프로파일. deny-default이며 마지막 규칙이 이김\n", + ";; airlock-generated profile; deny-default, the last rule wins\n" + )); out.push_str("(deny default)\n"); out.push_str("(deny file-write* (with no-report))\n\n"); - out.push_str(";; --- 프로세스 기본 동작 ---\n"); + out.push_str(tr!( + ";; --- 프로세스 기본 동작 ---\n", + ";; --- process basics ---\n" + )); out.push_str("(allow process-fork)\n"); if exec_whitelist { // 무조건 개방을 걷어내면 exec 이 블랙리스트에서 화이트리스트로 뒤집힙니다. // 허용은 아래 정책 allow 절에서 경로마다 하나씩 나갑니다 - out.push_str(";; exec 은 정책 allow 절에서 경로별로만 열림. 무조건 개방 없음\n"); + out.push_str(tr!( + ";; exec 은 정책 allow 절에서 경로별로만 열림. 무조건 개방 없음\n", + ";; exec opens only per path in the policy allow section; no unconditional opening\n" + )); } else { out.push_str("(allow process-exec*)\n"); } @@ -243,18 +253,33 @@ pub fn generate(policy: &Policy, opts: &ProfileOptions) -> GeneratedProfile { // mach 서비스는 통째로 열되 정책 밖 유출 통로로 알려진 것부터 되막습니다. 목록을 // 화이트리스트로 뒤집으면 개발 툴체인이 버전마다 깨지므로 deny 를 뒤에 둡니다 out.push_str("(allow mach-lookup)\n"); - out.push_str(";; 클립보드는 정책 모델 밖의 읽기 쓰기 통로임\n"); + out.push_str(tr!( + ";; 클립보드는 정책 모델 밖의 읽기 쓰기 통로임\n", + ";; the clipboard is a read-write channel outside the policy model\n" + )); out.push_str("(deny mach-lookup (global-name \"com.apple.pasteboard.1\"))\n"); out.push_str("(deny mach-lookup (global-name \"com.apple.pboard\"))\n\n"); - out.push_str(";; --- 경로 해석 ---\n"); - out.push_str(";; 루트 노드 자체를 읽지 못하면 어떤 절대 경로도 해석되지 않아\n"); - out.push_str(";; dyld가 라이브러리를 찾기 전에 프로세스가 죽음\n"); + out.push_str(tr!( + ";; --- 경로 해석 ---\n", + ";; --- path resolution ---\n" + )); + out.push_str(tr!( + ";; 루트 노드 자체를 읽지 못하면 어떤 절대 경로도 해석되지 않아\n", + ";; without read on the root node itself no absolute path resolves,\n" + )); + out.push_str(tr!( + ";; dyld가 라이브러리를 찾기 전에 프로세스가 죽음\n", + ";; and the process dies before dyld finds its libraries\n" + )); out.push_str("(allow file-read* (literal \"/\"))\n"); - out.push_str(";; 조상 디렉토리 탐색용. 아래 차단 규칙이 뒤에서 덮음\n"); + out.push_str(tr!( + ";; 조상 디렉토리 탐색용. 아래 차단 규칙이 뒤에서 덮음\n", + ";; for ancestor directory traversal; the deny rules below override it\n" + )); out.push_str("(allow file-read-metadata)\n\n"); - out.push_str(";; --- 시스템 읽기 ---\n"); + out.push_str(tr!(";; --- 시스템 읽기 ---\n", ";; --- system reads ---\n")); for p in SYSTEM_READ_SUBPATHS { out.push_str(&format!( "(allow file-read* (subpath {}))\n", @@ -263,7 +288,10 @@ pub fn generate(policy: &Policy, opts: &ProfileOptions) -> GeneratedProfile { } out.push('\n'); - out.push_str(";; --- 장치 노드 읽기 쓰기 ---\n"); + out.push_str(tr!( + ";; --- 장치 노드 읽기 쓰기 ---\n", + ";; --- device node read-write ---\n" + )); for p in DEV_RW_LITERALS { out.push_str(&format!( "(allow file-read* file-write* (literal {}))\n", @@ -273,7 +301,10 @@ pub fn generate(policy: &Policy, opts: &ProfileOptions) -> GeneratedProfile { out.push('\n'); if !opts.temp_dirs.is_empty() { - out.push_str(";; --- 임시 디렉토리 ---\n"); + out.push_str(tr!( + ";; --- 임시 디렉토리 ---\n", + ";; --- temp directories ---\n" + )); for dir in &opts.temp_dirs { match sbpl::subpath(dir) { Some(t) => { @@ -286,7 +317,7 @@ pub fn generate(policy: &Policy, opts: &ProfileOptions) -> GeneratedProfile { } if let Some(ws) = &opts.workspace { - out.push_str(";; --- 작업 공간 ---\n"); + out.push_str(tr!(";; --- 작업 공간 ---\n", ";; --- workspace ---\n")); match sbpl::subpath(ws) { Some(t) => out.push_str(&format!( "(allow file-read* file-write* {})\n\n", @@ -296,12 +327,18 @@ pub fn generate(policy: &Policy, opts: &ProfileOptions) -> GeneratedProfile { } } - out.push_str(";; --- 네트워크 ---\n"); + out.push_str(tr!(";; --- 네트워크 ---\n", ";; --- network ---\n")); if let Some(proxy) = opts.proxy.filter(|_| opts.allow_network) { // 아웃바운드가 프록시 하나로 좁혀지므로 호스트 규칙은 여기서 처음으로 // 실제 경계를 갖습니다. 판정은 프록시가 하고 커널은 우회를 막습니다 - out.push_str(";; 아웃바운드를 egress 프록시 하나로 좁힘\n"); - out.push_str(";; 호스트 판정은 프록시가 하고 커널은 다른 출구를 막음\n"); + out.push_str(tr!( + ";; 아웃바운드를 egress 프록시 하나로 좁힘\n", + ";; outbound narrowed to the single egress proxy\n" + )); + out.push_str(tr!( + ";; 호스트 판정은 프록시가 하고 커널은 다른 출구를 막음\n", + ";; the proxy decides hosts and the kernel blocks other exits\n" + )); out.push_str(&format!( "(allow network-outbound (remote ip {}))\n", sbpl::quote(&format!("localhost:{}", proxy.port())) @@ -309,20 +346,38 @@ pub fn generate(policy: &Policy, opts: &ProfileOptions) -> GeneratedProfile { // 유닉스 소켓까지 막으면 시스템 라이브러리가 대부분 동작하지 않습니다 out.push_str("(allow network-outbound (remote unix))\n\n"); } else if network_allowed(policy, opts) { - out.push_str(";; 주의 호스트 단위 제어는 Seatbelt로 표현할 수 없음\n"); - out.push_str(";; egress 정책은 프록시 층에서 강제함\n"); + out.push_str(tr!( + ";; 주의 호스트 단위 제어는 Seatbelt로 표현할 수 없음\n", + ";; note: per-host control cannot be expressed in Seatbelt\n" + )); + out.push_str(tr!( + ";; egress 정책은 프록시 층에서 강제함\n", + ";; egress policy is enforced at the proxy layer\n" + )); out.push_str("(allow network-outbound)\n"); out.push_str("(allow network-bind (local ip))\n\n"); // 아웃바운드가 열린 채로는 egress 제한 규칙 중 어느 것도 커널이 판정하지 못합니다 for rule in restrictive_egress_ids(policy) { - untranslatable.push(format!("{rule} (호스트·포트 egress)")); + untranslatable.push(tr!( + format!("{rule} (호스트·포트 egress)"), + format!("{rule} (host/port egress)") + )); } } else { - out.push_str(";; 정책에 egress allow 규칙이 없어 아웃바운드를 통째로 차단함\n"); - out.push_str(";; deny-default 프로파일이므로 규칙을 방출하지 않는 것이 곧 차단임\n\n"); + out.push_str(tr!( + ";; 정책에 egress allow 규칙이 없어 아웃바운드를 통째로 차단함\n", + ";; no egress allow rules in the policy, so outbound is blocked entirely\n" + )); + out.push_str(tr!( + ";; deny-default 프로파일이므로 규칙을 방출하지 않는 것이 곧 차단임\n\n", + ";; in a deny-default profile, emitting no rule is itself the block\n\n" + )); } - out.push_str(";; --- 정책 allow 규칙 ---\n"); + out.push_str(tr!( + ";; --- 정책 allow 규칙 ---\n", + ";; --- policy allow rules ---\n" + )); emit_file_rules(policy, &mut out, &mut untranslatable, |a| { a == Action::Allow }); @@ -330,7 +385,10 @@ pub fn generate(policy: &Policy, opts: &ProfileOptions) -> GeneratedProfile { emit_exec_allow(policy, opts, &mut out, &mut untranslatable, &mut exec_allow); } - out.push_str("\n;; --- 정책 차단 규칙. 마지막에 두어 어떤 allow도 덮지 못하게 함 ---\n"); + out.push_str(tr!( + "\n;; --- 정책 차단 규칙. 마지막에 두어 어떤 allow도 덮지 못하게 함 ---\n", + "\n;; --- policy deny rules; kept last so no allow can override them ---\n" + )); emit_file_rules(policy, &mut out, &mut untranslatable, |a| { matches!(a, Action::Deny | Action::Forbid | Action::Ask) }); @@ -399,7 +457,10 @@ fn emit_exec_allow( untranslatable: &mut Vec, emitted: &mut Vec, ) { - out.push_str(";; --- exec 화이트리스트. 여기 없는 프로그램은 커널이 거부함 ---\n"); + out.push_str(tr!( + ";; --- exec 화이트리스트. 여기 없는 프로그램은 커널이 거부함 ---\n", + ";; --- exec whitelist; programs not listed here are refused by the kernel ---\n" + )); let mut emit = |target: &sbpl::Target, id: &str, out: &mut String| { let rendered = target.render(); @@ -413,9 +474,15 @@ fn emit_exec_allow( if let Some(program) = &opts.program { match sbpl::literal(program) { Some(t) => emit(&t, "airlock:top-level", out), - None => untranslatable.push(format!( - "최상위 프로그램 {} (경로가 UTF-8이 아니라 SBPL 대상으로 옮길 수 없음)", - program.display() + None => untranslatable.push(tr!( + format!( + "최상위 프로그램 {} (경로가 UTF-8이 아니라 SBPL 대상으로 옮길 수 없음)", + program.display() + ), + format!( + "top-level program {} (path is not UTF-8, cannot be turned into an SBPL target)", + program.display() + ) )), } } @@ -451,9 +518,15 @@ fn exec_allow_targets( Some(ProgramMatch::Path(pattern)) => { let targets = sbpl::target_for(pattern).into_iter().collect::>(); if targets.is_empty() { - untranslatable.push(format!( - "{} (exec allow 경로 패턴을 SBPL 대상으로 옮길 수 없음)", - comment(id) + untranslatable.push(tr!( + format!( + "{} (exec allow 경로 패턴을 SBPL 대상으로 옮길 수 없음)", + comment(id) + ), + format!( + "{} (exec allow path pattern cannot be turned into an SBPL target)", + comment(id) + ) )); } targets @@ -461,18 +534,31 @@ fn exec_allow_targets( Some(ProgramMatch::Basename(name)) => { let found = resolve_in_path(name); if found.is_empty() { - untranslatable.push(format!( - "{} (program = \"{}\" 을 PATH 에서 찾지 못해 exec 허용에 넣지 않았음)", - comment(id), - comment(name) + untranslatable.push(tr!( + format!( + "{} (program = \"{}\" 을 PATH 에서 찾지 못해 exec 허용에 넣지 않았음)", + comment(id), + comment(name) + ), + format!( + "{} (program = \"{}\" was not found in PATH, so it was left out of the exec allow list)", + comment(id), + comment(name) + ) )); } found.iter().filter_map(|p| sbpl::literal(p)).collect() } None => { - untranslatable.push(format!( - "{} (프로그램 조건이 없는 exec allow 는 대상을 특정할 수 없음)", - comment(id) + untranslatable.push(tr!( + format!( + "{} (프로그램 조건이 없는 exec allow 는 대상을 특정할 수 없음)", + comment(id) + ), + format!( + "{} (an exec allow without a program condition cannot pin down its target)", + comment(id) + ) )); Vec::new() } @@ -482,10 +568,17 @@ fn exec_allow_targets( for pattern in paths { match sbpl::target_for(pattern) { Some(t) => out.push(t), - None => untranslatable.push(format!( - "{} {} (exec 허용 경로를 SBPL 대상으로 옮길 수 없음)", - comment(id), - comment(pattern.raw()) + None => untranslatable.push(tr!( + format!( + "{} {} (exec 허용 경로를 SBPL 대상으로 옮길 수 없음)", + comment(id), + comment(pattern.raw()) + ), + format!( + "{} {} (exec allow path cannot be turned into an SBPL target)", + comment(id), + comment(pattern.raw()) + ) )), } } @@ -544,21 +637,30 @@ fn exec_targets(matcher: &Matcher) -> std::result::Result, &'s argv_pattern, } = matcher else { - return Err("exec 규칙이 아님"); + return Err(tr!("exec 규칙이 아님", "not an exec rule")); }; if !argv_contains.is_empty() || argv_pattern.is_some() { // argv를 보고 좁힌 규칙을 프로그램 경로만으로 옮기면 정책보다 넓게 막습니다 - return Err("argv 조건은 Seatbelt가 볼 수 없어 표현 불가"); + return Err(tr!( + "argv 조건은 Seatbelt가 볼 수 없어 표현 불가", + "argv conditions are invisible to Seatbelt and cannot be expressed" + )); } let Some(pm) = program else { - return Err("프로그램 조건이 없어 실행 대상을 특정할 수 없음"); + return Err(tr!( + "프로그램 조건이 없어 실행 대상을 특정할 수 없음", + "no program condition, so the exec target cannot be pinned down" + )); }; let targets = match pm { ProgramMatch::Basename(name) => sbpl::basename_targets(name), ProgramMatch::Path(pattern) => sbpl::targets_for(pattern), }; if targets.is_empty() { - return Err("경로 패턴을 SBPL 대상으로 옮길 수 없음"); + return Err(tr!( + "경로 패턴을 SBPL 대상으로 옮길 수 없음", + "the path pattern cannot be turned into an SBPL target" + )); } Ok(targets) } @@ -624,7 +726,10 @@ fn emit_file_rules( } pub fn ask_rules_are_denied_note() -> &'static str { - "Seatbelt는 사람 승인을 표현할 수 없으므로 ask 파일 규칙은 프로파일에서 deny로 내려감" + tr!( + "Seatbelt는 사람 승인을 표현할 수 없으므로 ask 파일 규칙은 프로파일에서 deny로 내려감", + "Seatbelt cannot express human approval, so ask file rules degrade to deny in the profile" + ) } /// 화이트리스트 모드에서 `ask` exec 규칙이 어떻게 되는지. @@ -632,13 +737,23 @@ pub fn ask_rules_are_denied_note() -> &'static str { /// 허용 목록에 넣지 않으므로 커널이 실행을 거부합니다. 방출하지 않는다는 기구는 같지만 /// 결과가 반대이므로 배너 문구도 달라야 합니다 pub fn exec_ask_whitelist_note() -> &'static str { - "ask exec 규칙은 화이트리스트에 넣지 않으므로 커널이 실행을 거부함. macOS 에는 중계 층이 \ - 없어 사람에게 물을 방법이 없으며, 최상위 exec 하나만 브로커가 spawn 전에 물음" + tr!( + "ask exec 규칙은 화이트리스트에 넣지 않으므로 커널이 실행을 거부함. macOS 에는 중계 층이 \ + 없어 사람에게 물을 방법이 없으며, 최상위 exec 하나만 브로커가 spawn 전에 물음", + "ask exec rules are not put on the whitelist, so the kernel refuses those execs; \ + macOS has no mediation layer to ask a human through, and only the top-level exec \ + is asked by the broker before spawn" + ) } pub fn exec_ask_note() -> &'static str { - "ask exec 규칙은 커널에서 강제되지 않음. macOS 에서 답을 받을 수 있는 ask 는 브로커가 \ - spawn 전에 묻는 최상위 exec 하나뿐이라, deny 로 내리면 승인된 실행까지 막히기 때문임" + tr!( + "ask exec 규칙은 커널에서 강제되지 않음. macOS 에서 답을 받을 수 있는 ask 는 브로커가 \ + spawn 전에 묻는 최상위 exec 하나뿐이라, deny 로 내리면 승인된 실행까지 막히기 때문임", + "ask exec rules are not kernel-enforced; on macOS the only ask that can receive an \ + answer is the top-level exec the broker asks before spawn, so degrading them to \ + deny would block even approved execs" + ) } #[cfg(test)] diff --git a/crates/airlock-broker/src/seatbelt.rs b/crates/airlock-broker/src/seatbelt.rs index 537ec96..7622dc6 100644 --- a/crates/airlock-broker/src/seatbelt.rs +++ b/crates/airlock-broker/src/seatbelt.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use airlock_audit::Enforcement; +use airlock_i18n::tr; use airlock_policy::Policy; use crate::enforcer::Enforcer; @@ -106,7 +107,11 @@ impl SeatbeltEnforcer { .clone() .ok_or_else(|| BrokerError::EnforcerUnavailable { name: "seatbelt", - why: "prepare가 먼저 호출되지 않음".to_string(), + why: tr!( + "prepare가 먼저 호출되지 않음", + "prepare was not called first" + ) + .to_string(), }) }; if !self.exec_whitelist { @@ -123,15 +128,21 @@ impl SeatbeltEnforcer { if generated.exec_allow.is_empty() { return Err(BrokerError::EnforcerUnavailable { name: "seatbelt", - why: "[defaults].exec 이 allow 가 아니어서 exec 을 화이트리스트로 거는데 \ - 허용 목록이 비었음. 최상위 프로그램 경로를 해소하지 못했거나 \ - 정책에 exec allow 규칙이 하나도 없음. 이대로 걸면 프로세스가 뜨지 못함" - .to_string(), + why: tr!( + "[defaults].exec 이 allow 가 아니어서 exec 을 화이트리스트로 거는데 \ + 허용 목록이 비었음. 최상위 프로그램 경로를 해소하지 못했거나 \ + 정책에 exec allow 규칙이 하나도 없음. 이대로 걸면 프로세스가 뜨지 못함", + "[defaults].exec is not allow, so exec is whitelisted, but the allow \ + list is empty; the top-level program path could not be resolved, or \ + the policy has no exec allow rules at all; applying this would keep \ + the process from starting" + ) + .to_string(), }); } CString::new(generated.text).map_err(|_| BrokerError::EnforcerUnavailable { name: "seatbelt", - why: "프로파일에 null 바이트 존재".to_string(), + why: tr!("프로파일에 null 바이트 존재", "null byte in the profile").to_string(), }) } } @@ -153,9 +164,10 @@ unsafe fn apply_profile(profile: &CString) -> std::io::Result<()> { if !errbuf.is_null() { unsafe { sandbox_free_error(errbuf) }; } - Err(std::io::Error::other( + Err(std::io::Error::other(tr!( "sandbox_init_with_parameters가 프로파일 적용을 거부함", - )) + "sandbox_init_with_parameters refused to apply the profile" + ))) } impl Enforcer for SeatbeltEnforcer { @@ -190,7 +202,7 @@ impl Enforcer for SeatbeltEnforcer { .collect(); let text = CString::new(generated.text).map_err(|_| BrokerError::EnforcerUnavailable { name: "seatbelt", - why: "프로파일에 null 바이트 존재".to_string(), + why: tr!("프로파일에 null 바이트 존재", "null byte in the profile").to_string(), })?; self.compiled = Some(text); Ok(()) @@ -222,7 +234,7 @@ impl Enforcer for SeatbeltEnforcer { .to_str() .map_err(|_| BrokerError::EnforcerUnavailable { name: "seatbelt", - why: "프로파일이 UTF-8이 아님".to_string(), + why: tr!("프로파일이 UTF-8이 아님", "the profile is not UTF-8").to_string(), })? .to_string(); let program = cmd.get_program().to_os_string(); @@ -244,26 +256,49 @@ impl Enforcer for SeatbeltEnforcer { let egress_note = if self.options.proxy.is_some() { // 프록시가 붙어도 남는 구멍입니다. macOS 의 이름 해석은 // network-outbound 가 아니라 mDNSResponder 를 거치므로 이 경계 밖입니다 - "아웃바운드는 egress 프록시로 좁혀졌으나 DNS 질의는 프록시를 거치지 않음. \ - 자식이 이름 질의 자체를 반출 채널로 쓸 수 있음" - .to_string() + tr!( + "아웃바운드는 egress 프록시로 좁혀졌으나 DNS 질의는 프록시를 거치지 않음. \ + 자식이 이름 질의 자체를 반출 채널로 쓸 수 있음", + "outbound is narrowed to the egress proxy, but DNS queries do not pass \ + through it; the child can use name queries themselves as an exfiltration \ + channel" + ) + .to_string() } else { - "호스트 단위 egress 정책은 Seatbelt로 강제되지 않음. 프록시 층이 필요함".to_string() + tr!( + "호스트 단위 egress 정책은 Seatbelt로 강제되지 않음. 프록시 층이 필요함", + "per-host egress policy is not enforced by Seatbelt; a proxy layer is needed" + ) + .to_string() }; let mut gaps = vec![ egress_note, profile::ask_rules_are_denied_note().to_string(), - "커널이 거부한 개별 파일·네트워크 접근은 감사 로그에 남지 않음. \ - 체인에는 세션 단위 기록만 있음" - .to_string(), - "mach 서비스는 클립보드를 뺀 나머지가 열려 있음. XPC 로 오가는 것은 \ - 정책 모델과 감사 로그 밖임" - .to_string(), + tr!( + "커널이 거부한 개별 파일·네트워크 접근은 감사 로그에 남지 않음. \ + 체인에는 세션 단위 기록만 있음", + "individual file and network accesses the kernel refused are not in the \ + audit log; the chain only has session-level records" + ) + .to_string(), + tr!( + "mach 서비스는 클립보드를 뺀 나머지가 열려 있음. XPC 로 오가는 것은 \ + 정책 모델과 감사 로그 밖임", + "mach services other than the clipboard are open; whatever moves over XPC \ + is outside the policy model and the audit log" + ) + .to_string(), ]; if !self.untranslatable.is_empty() { - gaps.push(format!( - "프로파일로 옮기지 못한 규칙: {}", - self.untranslatable.join(", ") + gaps.push(tr!( + format!( + "프로파일로 옮기지 못한 규칙: {}", + self.untranslatable.join(", ") + ), + format!( + "rules that could not be moved into the profile: {}", + self.untranslatable.join(", ") + ) )); } if !self.ask_exec.is_empty() { @@ -275,16 +310,28 @@ impl Enforcer for SeatbeltEnforcer { gaps.push(format!("{note}: {}", self.ask_exec.join(", "))); } if !self.denied_overrides.is_empty() { - gaps.push(format!( - "override 완화는 커널 프로파일에 반영되지 않아 해당 경로가 커널에서 여전히 차단됨: {}", - self.denied_overrides.join(", ") + gaps.push(tr!( + format!( + "override 완화는 커널 프로파일에 반영되지 않아 해당 경로가 커널에서 여전히 차단됨: {}", + self.denied_overrides.join(", ") + ), + format!( + "override relaxations are not reflected in the kernel profile, so those \ + paths stay blocked in the kernel: {}", + self.denied_overrides.join(", ") + ) )); } if self.strategy == Strategy::SandboxExec { gaps.push( - "sandbox-exec 방식은 프로파일 전문을 argv로 넘기므로 같은 머신의 \ - 다른 프로세스가 ps로 정책 내용을 읽을 수 있음. 교차 검증 전용임" - .to_string(), + tr!( + "sandbox-exec 방식은 프로파일 전문을 argv로 넘기므로 같은 머신의 \ + 다른 프로세스가 ps로 정책 내용을 읽을 수 있음. 교차 검증 전용임", + "the sandbox-exec strategy passes the full profile via argv, so other \ + processes on the same machine can read the policy with ps; for \ + cross-validation only" + ) + .to_string(), ); } gaps diff --git a/crates/airlock-broker/src/session.rs b/crates/airlock-broker/src/session.rs index d1e6e9d..a975dfa 100644 --- a/crates/airlock-broker/src/session.rs +++ b/crates/airlock-broker/src/session.rs @@ -6,6 +6,7 @@ use airlock_audit::{ ANCHOR_FILE, AnchorLog, AuditLog, Decision, Enforcement, Event, GenesisInfo, Granted, Mediation, Record, }; +use airlock_i18n::tr; use airlock_policy::{Action, Evaluation, FileMode, MatchedRule, Policy, Tier}; use crate::approve::{ApprovalRequest, Approver}; @@ -251,33 +252,54 @@ pub fn mediation_gaps(requested: Mediation) -> Vec { let mut gaps = Vec::new(); if effective != requested { - gaps.push(format!( - "이 플랫폼에는 런타임 중계 기구가 없어 --mediate {}가 적용되지 않음. \ - 감사 로그에는 중계 수준 {}가 기록됨", - requested.as_str(), - effective.as_str() + gaps.push(tr!( + format!( + "이 플랫폼에는 런타임 중계 기구가 없어 --mediate {}가 적용되지 않음. \ + 감사 로그에는 중계 수준 {}가 기록됨", + requested.as_str(), + effective.as_str() + ), + format!( + "this platform has no runtime mediation mechanism, so --mediate {} does \ + not apply; the audit log records mediation level {}", + requested.as_str(), + effective.as_str() + ) )); } match effective { Mediation::Off => gaps.push( - "중계가 꺼져 있어 자식 프로세스의 exec·연결·파일 열기가 감사에 남지 않음. \ - 체인에는 세션 단위 기록만 있음" - .to_string(), + tr!( + "중계가 꺼져 있어 자식 프로세스의 exec·연결·파일 열기가 감사에 남지 않음. \ + 체인에는 세션 단위 기록만 있음", + "mediation is off, so the child's execs, connections, and file opens are \ + not audited; the chain only has session-level records" + ) + .to_string(), ), Mediation::ExecNet => gaps.push( - "파일 열기는 중계하지 않음. 파일 접근은 커널 강제만 받고 감사에는 남지 않음 \ - (--mediate full로 켤 수 있으나 느려짐)" - .to_string(), + tr!( + "파일 열기는 중계하지 않음. 파일 접근은 커널 강제만 받고 감사에는 남지 않음 \ + (--mediate full로 켤 수 있으나 느려짐)", + "file opens are not mediated; file access is only kernel-enforced and is \ + not audited (--mediate full can enable it, at a slowdown)" + ) + .to_string(), ), Mediation::Full => {} } if effective.observes() { gaps.push( - "중계 층이 읽은 경로와 커널이 실제로 여는 대상은 다를 수 있음(TOCTOU). \ - 실제 경계는 커널 강제 층이며 중계는 기록과 승인 채널임" - .to_string(), + tr!( + "중계 층이 읽은 경로와 커널이 실제로 여는 대상은 다를 수 있음(TOCTOU). \ + 실제 경계는 커널 강제 층이며 중계는 기록과 승인 채널임", + "the path the mediation layer read and what the kernel actually opens can \ + differ (TOCTOU); the real boundary is the kernel enforcement layer, and \ + mediation is a recording and approval channel" + ) + .to_string(), ); } gaps @@ -330,7 +352,7 @@ impl Session { config: &SessionConfig, ) -> Result { let session_id = audit::SessionId::generate().map_err(|source| BrokerError::Io { - what: "세션 식별자 생성".to_string(), + what: tr!("세션 식별자 생성", "session identifier generation").to_string(), source, })?; @@ -516,11 +538,11 @@ impl Session { let eval = self.policy.evaluate_file(path, mode, &cwd); let (requested, resolved) = self.resolve(&eval); - let mut request = ApprovalRequest::new("파일 접근 시도") - .fact("요청 경로", requested.clone()) - .fact("모드", mode.as_str()); + let mut request = ApprovalRequest::new(tr!("파일 접근 시도", "file access attempt")) + .fact(tr!("요청 경로", "requested path"), requested.clone()) + .fact(tr!("모드", "mode"), mode.as_str()); if requested != resolved { - request = request.fact("해소 경로", resolved.clone()); + request = request.fact(tr!("해소 경로", "resolved path"), resolved.clone()); } let event = Event::FileAccess { @@ -542,12 +564,12 @@ impl Session { let eval = self.policy.evaluate_exec(program, argv, &cwd); let (requested, resolved) = self.resolve(&eval); - let mut request = ApprovalRequest::new("프로세스 실행 시도") - .fact("프로그램", requested.clone()) + let mut request = ApprovalRequest::new(tr!("프로세스 실행 시도", "process exec attempt")) + .fact(tr!("프로그램", "program"), requested.clone()) .fact("argv", format!("{argv:?}")) .fact("cwd", cwd.to_string_lossy().into_owned()); if requested != resolved { - request = request.fact("해소 경로", resolved.clone()); + request = request.fact(tr!("해소 경로", "resolved path"), resolved.clone()); } let event = Event::Exec { @@ -580,7 +602,13 @@ impl Session { tier: Tier::SelfProtect, action: Action::Allow, pattern: format!("{host}:{port}"), - reason: Some("브로커 egress 프록시 채널. 목적지 판정은 프록시가 함".into()), + reason: Some( + tr!( + "브로커 egress 프록시 채널. 목적지 판정은 프록시가 함", + "broker egress proxy channel; destination decisions are made by the proxy" + ) + .into(), + ), }), path: None, } @@ -594,10 +622,11 @@ impl Session { self.bytes_out_to(host, port), ) }; - let request = ApprovalRequest::new("아웃바운드 연결 시도") - .fact("호스트", host.to_string()) - .fact("포트", port.to_string()) - .fact("프로토콜", protocol.as_str()); + let request = + ApprovalRequest::new(tr!("아웃바운드 연결 시도", "outbound connection attempt")) + .fact(tr!("호스트", "host"), host.to_string()) + .fact(tr!("포트", "port"), port.to_string()) + .fact(tr!("프로토콜", "protocol"), protocol.as_str()); let event = Event::Egress { host: host.to_string(), @@ -725,7 +754,11 @@ impl Session { let Some(seq) = head_seq else { return AnchorOutcome::Failed { path, - why: "세션 체인이 비어 있어 앵커할 head 가 없음".to_string(), + why: tr!( + "세션 체인이 비어 있어 앵커할 head 가 없음", + "the session chain is empty, so there is no head to anchor" + ) + .to_string(), }; }; let session = self.log.session(); @@ -736,7 +769,10 @@ impl Session { Err(e) => { return AnchorOutcome::Failed { path, - why: format!("앵커 잠금을 잡지 못함: {e}"), + why: tr!( + format!("앵커 잠금을 잡지 못함: {e}"), + format!("failed to take the anchor lock: {e}") + ), }; } }; @@ -1003,7 +1039,10 @@ pub fn run( let (supervisor, child_end) = start_supervisor(channel, &shared); let spawned = cmd.spawn().map_err(|source| BrokerError::Io { - what: format!("{} 실행", resolved.display()), + what: tr!( + format!("{} 실행", resolved.display()), + format!("running {}", resolved.display()) + ), source, }); @@ -1014,7 +1053,10 @@ pub fn run( let mut child = spawned?; let status = child.wait().map_err(|source| BrokerError::Io { - what: format!("{} 대기", resolved.display()), + what: tr!( + format!("{} 대기", resolved.display()), + format!("waiting for {}", resolved.display()) + ), source, })?; @@ -1085,7 +1127,16 @@ fn setup_mediation(cmd: &mut Command, level: Mediation) -> Option c, Err(e) => { // 중계를 조용히 끄면 감사 로그가 실제보다 완전해 보입니다 - eprintln!("airlock: 경고 런타임 중계를 켤 수 없음: {e}. 세션 단위 기록만 남음"); + eprintln!( + "{}", + tr!( + format!("airlock: 경고 런타임 중계를 켤 수 없음: {e}. 세션 단위 기록만 남음"), + format!( + "airlock: warning: cannot enable runtime mediation: {e}; only \ + session-level records remain" + ) + ) + ); return None; } }; @@ -1126,7 +1177,13 @@ fn start_supervisor( Err(e) => { // 자식이 필터를 걸지 못했거나 먼저 죽었습니다. 감사가 실제보다 완전해 // 보이지 않도록 사실을 알립니다 - eprintln!("airlock: 경고 중계 listener를 받지 못함: {e}"); + eprintln!( + "{}", + tr!( + format!("airlock: 경고 중계 listener를 받지 못함: {e}"), + format!("airlock: warning: failed to receive the mediation listener: {e}") + ) + ); } }); (Some((stop, handle)), Some(child_end)) From d4581e1af223335ea3a266358f08eaff988b41d8 Mon Sep 17 00:00:00 2001 From: Quant-TheodoreFelix <53819958+Quant-TheodoreFelix@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:48:50 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=EC=A0=95=EC=B1=85=EA=B3=BC=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EC=B8=B5=20=EC=B6=9C=EB=A0=A5=EC=9D=84=20=EB=A1=9C?= =?UTF-8?q?=EC=BC=80=EC=9D=BC=EC=97=90=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `LoadError` 와 `LoadWarning` 과 감사 검증 실패 사유와 확인 기록 판정을 두 언어로 냄 - 다이제스트에 인코딩되는 베이스라인 규칙 근거는 번역하지 않아 로케일이 정책 다이제스트를 바꾸지 않음 - 프록시 층은 비테스트 출력이 프로토콜 토큰뿐이라 그대로 둠 Co-authored-by: Claude Fable 5 --- crates/airlock-audit/Cargo.toml | 1 + crates/airlock-audit/src/anchor.rs | 156 ++++++++++-- crates/airlock-audit/src/error.rs | 72 +++++- crates/airlock-audit/src/log.rs | 19 +- crates/airlock-audit/src/review.rs | 134 ++++++++-- crates/airlock-audit/src/verify.rs | 235 ++++++++++++++---- crates/airlock-canonical/Cargo.toml | 1 + crates/airlock-canonical/src/hex.rs | 19 +- crates/airlock-policy/Cargo.toml | 1 + crates/airlock-policy/src/baseline.rs | 26 +- crates/airlock-policy/src/dsl.rs | 6 +- crates/airlock-policy/src/engine.rs | 36 ++- crates/airlock-policy/src/error.rs | 341 +++++++++++++++++++++----- crates/airlock-policy/src/glob.rs | 44 +++- crates/airlock-policy/src/host.rs | 31 ++- crates/airlock-policy/src/path.rs | 19 +- 16 files changed, 939 insertions(+), 202 deletions(-) diff --git a/crates/airlock-audit/Cargo.toml b/crates/airlock-audit/Cargo.toml index e90caeb..d9e46db 100644 --- a/crates/airlock-audit/Cargo.toml +++ b/crates/airlock-audit/Cargo.toml @@ -9,6 +9,7 @@ description = "Airlock의 해시체인 append-only 감사 로그와 검증기" [dependencies] airlock-canonical.workspace = true +airlock-i18n.workspace = true libc.workspace = true sha2.workspace = true serde.workspace = true diff --git a/crates/airlock-audit/src/anchor.rs b/crates/airlock-audit/src/anchor.rs index 9edfc6d..d221427 100644 --- a/crates/airlock-audit/src/anchor.rs +++ b/crates/airlock-audit/src/anchor.rs @@ -34,6 +34,7 @@ use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use airlock_canonical::Encoder; +use airlock_i18n::tr; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -300,39 +301,98 @@ impl fmt::Display for AnchorFailure { match self { Self::FileAbsent => write!( f, - "{ANCHOR_FILE} 없음. 세션 삭제와 체인 재계산을 탐지할 수 없음. 앵커 삭제 의심" + "{}", + tr!( + format!( + "{ANCHOR_FILE} 없음. 세션 삭제와 체인 재계산을 탐지할 수 없음. 앵커 삭제 의심" + ), + format!( + "{ANCHOR_FILE} missing; session deletion and chain recomputation cannot be detected. Suspected anchor deletion" + ) + ) ), - Self::ChainEmpty => write!(f, "앵커 체인이 비어 있음"), - Self::MalformedLine { line, detail } => { - write!(f, "앵커 {line}번째 줄 파싱 실패: {detail}") - } - Self::TruncatedFinalLine { line } => { - write!(f, "앵커 {line}번째 줄이 개행 없이 잘림. 쓰기 중 중단 의심") + Self::ChainEmpty => { + f.write_str(tr!("앵커 체인이 비어 있음", "the anchor chain is empty")) } + Self::MalformedLine { line, detail } => write!( + f, + "{}", + tr!( + format!("앵커 {line}번째 줄 파싱 실패: {detail}"), + format!("failed to parse anchor line {line}: {detail}") + ) + ), + Self::TruncatedFinalLine { line } => write!( + f, + "{}", + tr!( + format!("앵커 {line}번째 줄이 개행 없이 잘림. 쓰기 중 중단 의심"), + format!( + "anchor line {line} is truncated without a newline; suspected interruption mid-write" + ) + ) + ), Self::BlankLine { line } => write!( f, - "앵커 {line}번째 줄이 비어 있음. 앵커가 아닌 줄이 끼어들었음" + "{}", + tr!( + format!("앵커 {line}번째 줄이 비어 있음. 앵커가 아닌 줄이 끼어들었음"), + format!("anchor line {line} is blank; a non-anchor line crept in") + ) ), Self::FormatVersionUnsupported { seq, got } => write!( f, - "앵커 seq {seq}의 포맷이 v{got}. 이 검증자는 v{ANCHOR_VERSION}만 안다. 변조가 아니라 옛 포맷일 수 있으니 그 버전의 airlock으로 검증할 것" + "{}", + tr!( + format!( + "앵커 seq {seq}의 포맷이 v{got}. 이 검증자는 v{ANCHOR_VERSION}만 안다. 변조가 아니라 옛 포맷일 수 있으니 그 버전의 airlock으로 검증할 것" + ), + format!( + "anchor seq {seq} has format v{got}; this verifier only knows v{ANCHOR_VERSION}. It may be an old format rather than tampering, so verify with that version of airlock" + ) + ) ), - Self::GenesisPrevNotZero { got } => { - write!(f, "첫 앵커의 prev가 0이 아님: {got}") - } - Self::SeqGap { expected, got } => { - write!( - f, - "앵커 seq 빈틈. {expected} 기대, {got} 발견. 앵커 줄 삭제 의심" + Self::GenesisPrevNotZero { got } => write!( + f, + "{}", + tr!( + format!("첫 앵커의 prev가 0이 아님: {got}"), + format!("the first anchor's prev is not zero: {got}") ) - } + ), + Self::SeqGap { expected, got } => write!( + f, + "{}", + tr!( + format!("앵커 seq 빈틈. {expected} 기대, {got} 발견. 앵커 줄 삭제 의심"), + format!( + "anchor seq gap; expected {expected}, found {got}. Suspected anchor line deletion" + ) + ) + ), Self::PrevMismatch { seq, expected, got } => write!( f, - "앵커 seq {seq}의 prev 불일치. {expected} 기대, {got} 발견. 삭제·삽입·재배치 의심" + "{}", + tr!( + format!( + "앵커 seq {seq}의 prev 불일치. {expected} 기대, {got} 발견. 삭제·삽입·재배치 의심" + ), + format!( + "anchor seq {seq} prev mismatch; expected {expected}, found {got}. Suspected deletion, insertion, or reordering" + ) + ) ), Self::HashMismatch { seq, expected, got } => write!( f, - "앵커 seq {seq}의 hash 불일치. 재계산 {expected}, 기록 {got}. 내용 변조 의심" + "{}", + tr!( + format!( + "앵커 seq {seq}의 hash 불일치. 재계산 {expected}, 기록 {got}. 내용 변조 의심" + ), + format!( + "anchor seq {seq} hash mismatch; recomputed {expected}, recorded {got}. Suspected content tampering" + ) + ) ), Self::SessionDuplicated { seq, @@ -340,7 +400,15 @@ impl fmt::Display for AnchorFailure { first_seq, } => write!( f, - "앵커 seq {seq}가 세션 {session}을 같은 head_seq로 다시 기록함 (처음은 seq {first_seq}). 앵커 줄 복제 의심" + "{}", + tr!( + format!( + "앵커 seq {seq}가 세션 {session}을 같은 head_seq로 다시 기록함 (처음은 seq {first_seq}). 앵커 줄 복제 의심" + ), + format!( + "anchor seq {seq} records session {session} again with the same head_seq (first at seq {first_seq}); suspected anchor line duplication" + ) + ) ), Self::HeadSeqRegressed { seq, @@ -349,7 +417,15 @@ impl fmt::Display for AnchorFailure { got, } => write!( f, - "앵커 seq {seq}에서 세션 {session}의 head_seq가 되감김 ({previous} -> {got}). 세션 체인은 자라기만 하므로 잘라내기 의심" + "{}", + tr!( + format!( + "앵커 seq {seq}에서 세션 {session}의 head_seq가 되감김 ({previous} -> {got}). 세션 체인은 자라기만 하므로 잘라내기 의심" + ), + format!( + "head_seq of session {session} regressed at anchor seq {seq} ({previous} -> {got}); a session chain only grows, so truncation is suspected" + ) + ) ), Self::SessionHeadMismatch { session, @@ -362,12 +438,28 @@ impl fmt::Display for AnchorFailure { if chain_head_seq > anchor_head_seq { write!( f, - "세션 {session}의 체인이 앵커보다 김. 앵커(seq {anchor_seq})는 head {anchor_head_seq}, 체인은 head {chain_head_seq}. 앵커 이후 덧붙이기 의심" + "{}", + tr!( + format!( + "세션 {session}의 체인이 앵커보다 김. 앵커(seq {anchor_seq})는 head {anchor_head_seq}, 체인은 head {chain_head_seq}. 앵커 이후 덧붙이기 의심" + ), + format!( + "the chain of session {session} is longer than the anchor; the anchor (seq {anchor_seq}) says head {anchor_head_seq}, the chain says head {chain_head_seq}. Suspected append after anchoring" + ) + ) ) } else { write!( f, - "세션 {session}의 head가 앵커와 다름. 앵커(seq {anchor_seq})는 {anchor_head_seq}/{anchor_head_hash}, 체인은 {chain_head_seq}/{chain_head_hash}. 체인 재계산 의심" + "{}", + tr!( + format!( + "세션 {session}의 head가 앵커와 다름. 앵커(seq {anchor_seq})는 {anchor_head_seq}/{anchor_head_hash}, 체인은 {chain_head_seq}/{chain_head_hash}. 체인 재계산 의심" + ), + format!( + "the head of session {session} differs from the anchor; the anchor (seq {anchor_seq}) says {anchor_head_seq}/{anchor_head_hash}, the chain says {chain_head_seq}/{chain_head_hash}. Suspected chain recomputation" + ) + ) ) } } @@ -389,11 +481,25 @@ impl fmt::Display for AnchorWarning { match self { Self::ClockWentBackwards { seq, prev_ts, ts } => write!( f, - "앵커 seq {seq}에서 벽시계가 역행 ({prev_ts} -> {ts}). 시각 조정 가능성" + "{}", + tr!( + format!( + "앵커 seq {seq}에서 벽시계가 역행 ({prev_ts} -> {ts}). 시각 조정 가능성" + ), + format!( + "wall clock went backwards at anchor seq {seq} ({prev_ts} -> {ts}); possible clock adjustment" + ) + ) ), Self::SessionReanchored { session, count } => write!( f, - "세션 {session}이 {count}번 앵커됨. 중간 체크포인트로 판단" + "{}", + tr!( + format!("세션 {session}이 {count}번 앵커됨. 중간 체크포인트로 판단"), + format!( + "session {session} anchored {count} times; judged to be intermediate checkpoints" + ) + ) ), } } diff --git a/crates/airlock-audit/src/error.rs b/crates/airlock-audit/src/error.rs index 7e20685..af0de6d 100644 --- a/crates/airlock-audit/src/error.rs +++ b/crates/airlock-audit/src/error.rs @@ -1,6 +1,8 @@ use std::fmt; use std::path::PathBuf; +use airlock_i18n::tr; + #[derive(Debug)] pub enum Error { Io { @@ -33,25 +35,75 @@ impl Error { impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Io { path, source } => write!(f, "{} 입출력 실패: {source}", path.display()), - Self::Json(e) => write!(f, "JSON 직렬화 실패: {e}"), + Self::Io { path, source } => write!( + f, + "{}", + tr!( + format!("{} 입출력 실패: {source}", path.display()), + format!("I/O failure on {}: {source}", path.display()) + ) + ), + Self::Json(e) => write!( + f, + "{}", + tr!( + format!("JSON 직렬화 실패: {e}"), + format!("JSON serialization failure: {e}") + ) + ), Self::SessionDirExists(p) => write!( f, - "{} 이미 존재함. 기존 체인에 이어 붙이는 것은 허용하지 않음", - p.display() + "{}", + tr!( + format!( + "{} 이미 존재함. 기존 체인에 이어 붙이는 것은 허용하지 않음", + p.display() + ), + format!( + "{} already exists; appending to an existing chain is not allowed", + p.display() + ) + ) + ), + Self::ChainMissing(p) => write!( + f, + "{}", + tr!( + format!("{}에 chain.jsonl이 없음", p.display()), + format!("no chain.jsonl in {}", p.display()) + ) ), - Self::ChainMissing(p) => write!(f, "{}에 chain.jsonl이 없음", p.display()), Self::AnchorChainBroken { path, detail } => write!( f, - "{} 앵커 체인이 이미 깨져 있음: {detail}. 깨진 체인 위에 이어 붙이면 그 뒤쪽이 정당해 보임", - path.display() + "{}", + tr!( + format!( + "{} 앵커 체인이 이미 깨져 있음: {detail}. 깨진 체인 위에 이어 붙이면 그 뒤쪽이 정당해 보임", + path.display() + ), + format!( + "the anchor chain at {} is already broken: {detail}; appending on top of a broken chain makes what follows look legitimate", + path.display() + ) + ) ), Self::ReviewChainBroken { path, detail } => write!( f, - "{} 확인 체인이 이미 깨져 있음: {detail}. 깨진 체인 위에 이어 붙이면 그 뒤쪽이 정당해 보임", - path.display() + "{}", + tr!( + format!( + "{} 확인 체인이 이미 깨져 있음: {detail}. 깨진 체인 위에 이어 붙이면 그 뒤쪽이 정당해 보임", + path.display() + ), + format!( + "the review chain at {} is already broken: {detail}; appending on top of a broken chain makes what follows look legitimate", + path.display() + ) + ) ), - Self::SeqOverflow => write!(f, "seq가 u64 범위를 넘음"), + Self::SeqOverflow => { + f.write_str(tr!("seq가 u64 범위를 넘음", "seq exceeds the u64 range")) + } } } } diff --git a/crates/airlock-audit/src/log.rs b/crates/airlock-audit/src/log.rs index 3b3485a..06c1f09 100644 --- a/crates/airlock-audit/src/log.rs +++ b/crates/airlock-audit/src/log.rs @@ -3,6 +3,7 @@ use std::io::Write; use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; +use airlock_i18n::tr; use serde::{Deserialize, Serialize}; use crate::entry::{Entry, Record}; @@ -245,18 +246,24 @@ pub fn read_entries_lossy(dir: impl AsRef) -> Result<(Vec, Option(&line) { Ok(entry) => entries.push(entry), Err(e) => { - problem = Some(format!( - "{}번째 줄부터 읽을 수 없음: {e}", - idx.saturating_add(1) + problem = Some(tr!( + format!("{}번째 줄부터 읽을 수 없음: {e}", idx.saturating_add(1)), + format!("cannot read from line {}: {e}", idx.saturating_add(1)) )); break; } diff --git a/crates/airlock-audit/src/review.rs b/crates/airlock-audit/src/review.rs index 54c0e51..4404830 100644 --- a/crates/airlock-audit/src/review.rs +++ b/crates/airlock-audit/src/review.rs @@ -33,6 +33,7 @@ use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use airlock_canonical::Encoder; +use airlock_i18n::tr; use serde::{Deserialize, Serialize}; use crate::error::Error; @@ -79,8 +80,8 @@ impl Verdict { /// 사람이 읽는 한국어 표기. pub fn label(self) -> &'static str { match self { - Self::Clean => "정상", - Self::Anomalous => "이상", + Self::Clean => tr!("정상", "clean"), + Self::Anomalous => tr!("이상", "anomalous"), } } } @@ -425,47 +426,122 @@ impl fmt::Display for ReviewFailure { match self { Self::FileAbsent => write!( f, - "{REVIEW_FILE} 없음. 매일 이상여부 점검과 책임자 확인 기록이 없음" + "{}", + tr!( + format!("{REVIEW_FILE} 없음. 매일 이상여부 점검과 책임자 확인 기록이 없음"), + format!( + "{REVIEW_FILE} missing; there is no record of daily anomaly checks and responsible-person sign-off" + ) + ) ), - Self::ChainEmpty => write!(f, "확인 체인이 비어 있음"), - Self::MalformedLine { line, detail } => { - write!(f, "확인 {line}번째 줄 파싱 실패: {detail}") - } - Self::TruncatedFinalLine { line } => { - write!(f, "확인 {line}번째 줄이 개행 없이 잘림. 쓰기 중 중단 의심") + Self::ChainEmpty => { + f.write_str(tr!("확인 체인이 비어 있음", "the review chain is empty")) } + Self::MalformedLine { line, detail } => write!( + f, + "{}", + tr!( + format!("확인 {line}번째 줄 파싱 실패: {detail}"), + format!("failed to parse review line {line}: {detail}") + ) + ), + Self::TruncatedFinalLine { line } => write!( + f, + "{}", + tr!( + format!("확인 {line}번째 줄이 개행 없이 잘림. 쓰기 중 중단 의심"), + format!( + "review line {line} is truncated without a newline; suspected interruption mid-write" + ) + ) + ), Self::BlankLine { line } => write!( f, - "확인 {line}번째 줄이 비어 있음. 확인 줄이 아닌 줄이 끼어들었음" + "{}", + tr!( + format!("확인 {line}번째 줄이 비어 있음. 확인 줄이 아닌 줄이 끼어들었음"), + format!("review line {line} is blank; a non-review line crept in") + ) ), Self::FormatVersionUnsupported { seq, got } => write!( f, - "확인 seq {seq}의 포맷이 v{got}. 이 검증자는 v{REVIEW_VERSION}만 안다" + "{}", + tr!( + format!("확인 seq {seq}의 포맷이 v{got}. 이 검증자는 v{REVIEW_VERSION}만 안다"), + format!( + "review seq {seq} has format v{got}; this verifier only knows v{REVIEW_VERSION}" + ) + ) + ), + Self::GenesisPrevNotZero { got } => write!( + f, + "{}", + tr!( + format!("첫 확인 줄의 prev가 0이 아님: {got}"), + format!("the first review line's prev is not zero: {got}") + ) ), - Self::GenesisPrevNotZero { got } => { - write!(f, "첫 확인 줄의 prev가 0이 아님: {got}") - } Self::SeqGap { expected, got } => write!( f, - "확인 seq 빈틈. {expected} 기대, {got} 발견. 확인 줄 삭제 의심" + "{}", + tr!( + format!("확인 seq 빈틈. {expected} 기대, {got} 발견. 확인 줄 삭제 의심"), + format!( + "review seq gap; expected {expected}, found {got}. Suspected review line deletion" + ) + ) ), Self::PrevMismatch { seq, expected, got } => write!( f, - "확인 seq {seq}의 prev 불일치. {expected} 기대, {got} 발견. 삭제·삽입·재배치 의심" + "{}", + tr!( + format!( + "확인 seq {seq}의 prev 불일치. {expected} 기대, {got} 발견. 삭제·삽입·재배치 의심" + ), + format!( + "review seq {seq} prev mismatch; expected {expected}, found {got}. Suspected deletion, insertion, or reordering" + ) + ) ), Self::HashMismatch { seq, expected, got } => write!( f, - "확인 seq {seq}의 hash 불일치. 재계산 {expected}, 기록 {got}. 내용 변조 의심" + "{}", + tr!( + format!( + "확인 seq {seq}의 hash 불일치. 재계산 {expected}, 기록 {got}. 내용 변조 의심" + ), + format!( + "review seq {seq} hash mismatch; recomputed {expected}, recorded {got}. Suspected content tampering" + ) + ) ), Self::ScopeMismatch { seq } => write!( f, - "확인 seq {seq}의 범위가 지금 계산한 리포트의 범위와 다름. \ + "{}", + tr!( + format!( + "확인 seq {seq}의 범위가 지금 계산한 리포트의 범위와 다름. \ 보지 않은 범위에 찍힌 도장임" + ), + format!( + "the scope of review seq {seq} differs from the scope of the report \ + computed now; it is a stamp on a range never looked at" + ) + ) ), Self::DigestMismatch { seq, expected, got } => write!( f, - "확인 seq {seq}의 리포트 다이제스트 불일치. 재계산 {expected}, 기록 {got}. \ + "{}", + tr!( + format!( + "확인 seq {seq}의 리포트 다이제스트 불일치. 재계산 {expected}, 기록 {got}. \ 확인한 리포트와 지금 리포트가 다름" + ), + format!( + "review seq {seq} report digest mismatch; recomputed {expected}, recorded \ + {got}. The reviewed report and the current report differ" + ) + ) ), Self::Io(e) => write!(f, "{e}"), } @@ -492,11 +568,27 @@ impl fmt::Display for ReviewWarning { match self { Self::ClockWentBackwards { seq, prev_ts, ts } => write!( f, - "확인 seq {seq}에서 벽시계가 역행 ({prev_ts} -> {ts}). 시각 조정 가능성" + "{}", + tr!( + format!( + "확인 seq {seq}에서 벽시계가 역행 ({prev_ts} -> {ts}). 시각 조정 가능성" + ), + format!( + "wall clock went backwards at review seq {seq} ({prev_ts} -> {ts}); possible clock adjustment" + ) + ) ), Self::TerminalNotObserved { seq } => write!( f, - "확인 seq {seq}에 터미널이 관측되지 않음. 사람이 앉아 있었다는 근거는 아님" + "{}", + tr!( + format!( + "확인 seq {seq}에 터미널이 관측되지 않음. 사람이 앉아 있었다는 근거는 아님" + ), + format!( + "no terminal observed at review seq {seq}; this is not evidence that a person was present" + ) + ) ), } } diff --git a/crates/airlock-audit/src/verify.rs b/crates/airlock-audit/src/verify.rs index cc73b35..bf8f9d5 100644 --- a/crates/airlock-audit/src/verify.rs +++ b/crates/airlock-audit/src/verify.rs @@ -4,6 +4,8 @@ use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::Path; +use airlock_i18n::tr; + use crate::entry::Entry; use crate::error::Error; use crate::event::Event; @@ -82,78 +84,172 @@ pub enum Failure { impl fmt::Display for Failure { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::ChainEmpty => write!(f, "체인이 비어 있음"), + Self::ChainEmpty => f.write_str(tr!("체인이 비어 있음", "the chain is empty")), Self::FormatVersionUnsupported { seq, got } => write!( f, - "seq {seq}의 포맷이 v{got}. 이 검증자는 v{}만 안다. 변조가 아니라 옛 포맷일 수 있으니 그 버전의 airlock으로 검증할 것", - crate::FORMAT_VERSION + "{}", + tr!( + format!( + "seq {seq}의 포맷이 v{got}. 이 검증자는 v{}만 안다. 변조가 아니라 옛 포맷일 수 있으니 그 버전의 airlock으로 검증할 것", + crate::FORMAT_VERSION + ), + format!( + "seq {seq} has format v{got}; this verifier only knows v{}. It may be an old format rather than tampering, so verify with that version of airlock", + crate::FORMAT_VERSION + ) + ) + ), + Self::MalformedLine { line, detail } => write!( + f, + "{}", + tr!( + format!("{line}번째 줄 파싱 실패: {detail}"), + format!("failed to parse line {line}: {detail}") + ) + ), + Self::TruncatedFinalLine { line } => write!( + f, + "{}", + tr!( + format!("{line}번째 줄이 개행 없이 잘림. 쓰기 중 중단 의심"), + format!( + "line {line} is truncated without a newline; suspected interruption mid-write" + ) + ) + ), + Self::GenesisPrevNotZero { got } => write!( + f, + "{}", + tr!( + format!("genesis의 prev가 0이 아님: {got}"), + format!("genesis prev is not zero: {got}") + ) + ), + Self::GenesisNotSessionStart { got } => write!( + f, + "{}", + tr!( + format!("genesis 이벤트가 session_start가 아님: {got}"), + format!("genesis event is not session_start: {got}") + ) + ), + Self::SeqGap { expected, got } => write!( + f, + "{}", + tr!( + format!("seq 빈틈. {expected} 기대, {got} 발견"), + format!("seq gap; expected {expected}, found {got}") + ) ), - Self::MalformedLine { line, detail } => { - write!(f, "{line}번째 줄 파싱 실패: {detail}") - } - Self::TruncatedFinalLine { line } => { - write!(f, "{line}번째 줄이 개행 없이 잘림. 쓰기 중 중단 의심") - } - Self::GenesisPrevNotZero { got } => { - write!(f, "genesis의 prev가 0이 아님: {got}") - } - Self::GenesisNotSessionStart { got } => { - write!(f, "genesis 이벤트가 session_start가 아님: {got}") - } - Self::SeqGap { expected, got } => { - write!(f, "seq 빈틈. {expected} 기대, {got} 발견") - } Self::SessionMismatch { seq, expected, got } => write!( f, - "seq {seq}의 session이 체인과 다름. {expected} 기대, {got} 발견" + "{}", + tr!( + format!("seq {seq}의 session이 체인과 다름. {expected} 기대, {got} 발견"), + format!( + "session of seq {seq} differs from the chain; expected {expected}, found {got}" + ) + ) ), Self::PrevMismatch { seq, expected, got } => write!( f, - "seq {seq}의 prev 불일치. {expected} 기대, {got} 발견. 삭제·삽입·재배치 의심" + "{}", + tr!( + format!( + "seq {seq}의 prev 불일치. {expected} 기대, {got} 발견. 삭제·삽입·재배치 의심" + ), + format!( + "seq {seq} prev mismatch; expected {expected}, found {got}. Suspected deletion, insertion, or reordering" + ) + ) ), Self::HashMismatch { seq, expected, got } => write!( f, - "seq {seq}의 hash 불일치. 재계산 {expected}, 기록 {got}. 내용 변조 의심" + "{}", + tr!( + format!( + "seq {seq}의 hash 불일치. 재계산 {expected}, 기록 {got}. 내용 변조 의심" + ), + format!( + "seq {seq} hash mismatch; recomputed {expected}, recorded {got}. Suspected content tampering" + ) + ) + ), + Self::ApprovalTargetMissing { seq, for_seq } => write!( + f, + "{}", + tr!( + format!("seq {seq} approval이 존재하지 않는 seq {for_seq}를 참조"), + format!("seq {seq} approval references nonexistent seq {for_seq}") + ) + ), + Self::ApprovalTargetNotAsk { seq, for_seq } => write!( + f, + "{}", + tr!( + format!("seq {seq} approval의 대상 seq {for_seq}가 ask가 아님"), + format!("seq {seq} approval targets seq {for_seq}, which is not an ask") + ) ), - Self::ApprovalTargetMissing { seq, for_seq } => { - write!(f, "seq {seq} approval이 존재하지 않는 seq {for_seq}를 참조") - } - Self::ApprovalTargetNotAsk { seq, for_seq } => { - write!(f, "seq {seq} approval의 대상 seq {for_seq}가 ask가 아님") - } Self::HeadMismatch { head, chain_seq, chain_hash, } => write!( f, - "앵커 불일치. head는 seq {} hash {}, 체인은 seq {chain_seq} hash {chain_hash}. 잘라내기 의심", - head.seq, head.hash + "{}", + tr!( + format!( + "앵커 불일치. head는 seq {} hash {}, 체인은 seq {chain_seq} hash {chain_hash}. 잘라내기 의심", + head.seq, head.hash + ), + format!( + "anchor mismatch; head says seq {} hash {}, the chain says seq {chain_seq} hash {chain_hash}. Suspected truncation", + head.seq, head.hash + ) + ) ), Self::HeadSessionMismatch { head_session, chain_session, } => write!( f, - "앵커 session 불일치. head {head_session}, 체인 {chain_session}" + "{}", + tr!( + format!("앵커 session 불일치. head {head_session}, 체인 {chain_session}"), + format!("anchor session mismatch; head {head_session}, chain {chain_session}") + ) ), - Self::HeadAbsent => write!( + Self::HeadAbsent => f.write_str(tr!( + "head.json 없음. 앵커 없이는 잘라내기를 탐지할 수 없음. 앵커 삭제 의심", + "head.json missing; truncation cannot be detected without the anchor. Suspected anchor deletion" + )), + Self::HeadUnreadable { detail } => write!( f, - "head.json 없음. 앵커 없이는 잘라내기를 탐지할 수 없음. 앵커 삭제 의심" + "{}", + tr!( + format!("head.json을 읽을 수 없음: {detail}. 앵커 없이는 잘라내기를 탐지할 수 없음"), + format!( + "cannot read head.json: {detail}; truncation cannot be detected without the anchor" + ) + ) ), - Self::HeadUnreadable { detail } => write!( + Self::HeadVersionUnsupported { got } => write!( f, - "head.json을 읽을 수 없음: {detail}. 앵커 없이는 잘라내기를 탐지할 수 없음" + "{}", + tr!( + format!("head.json version {got}는 지원하지 않음. 1이어야 함"), + format!("head.json version {got} is not supported; it must be 1") + ) ), - Self::HeadVersionUnsupported { got } => { - write!(f, "head.json version {got}는 지원하지 않음. 1이어야 함") - } - Self::BlankLine { line } => { - write!( - f, - "{line}번째 줄이 비어 있음. 엔트리가 아닌 줄이 끼어들었음" + Self::BlankLine { line } => write!( + f, + "{}", + tr!( + format!("{line}번째 줄이 비어 있음. 엔트리가 아닌 줄이 끼어들었음"), + format!("line {line} is blank; a non-entry line crept in") ) - } + ), Self::Io(e) => write!(f, "{e}"), } } @@ -176,26 +272,61 @@ impl fmt::Display for Warning { match self { Self::ClockWentBackwards { seq, prev_ts, ts } => write!( f, - "seq {seq}에서 벽시계가 역행 ({prev_ts} -> {ts}). 시각 조정 가능성" + "{}", + tr!( + format!("seq {seq}에서 벽시계가 역행 ({prev_ts} -> {ts}). 시각 조정 가능성"), + format!( + "wall clock went backwards at seq {seq} ({prev_ts} -> {ts}); possible clock adjustment" + ) + ) ), Self::HeadLagsByOne { head_seq, chain_seq, } => write!( f, - "앵커가 한 칸 뒤처짐 (head {head_seq}, 체인 {chain_seq}). 크래시 잔여로 판단" + "{}", + tr!( + format!( + "앵커가 한 칸 뒤처짐 (head {head_seq}, 체인 {chain_seq}). 크래시 잔여로 판단" + ), + format!( + "the anchor lags by one (head {head_seq}, chain {chain_seq}); judged to be crash residue" + ) + ) + ), + Self::DuplicateApproval { seq, for_seq } => write!( + f, + "{}", + tr!( + format!("seq {seq}가 이미 응답된 seq {for_seq}를 다시 승인"), + format!("seq {seq} approves already-answered seq {for_seq} again") + ) + ), + Self::UnansweredAsk { seq } => write!( + f, + "{}", + tr!( + format!("seq {seq}의 ask에 대한 approval 엔트리가 없음"), + format!("no approval entry for the ask at seq {seq}") + ) ), - Self::DuplicateApproval { seq, for_seq } => { - write!(f, "seq {seq}가 이미 응답된 seq {for_seq}를 다시 승인") - } - Self::UnansweredAsk { seq } => { - write!(f, "seq {seq}의 ask에 대한 approval 엔트리가 없음") - } Self::ObserveOnlyEntries { count } => write!( f, - "{count}개 엔트리가 observe 모드에서 기록됨. 강제되지 않은 관찰 기록임" + "{}", + tr!( + format!( + "{count}개 엔트리가 observe 모드에서 기록됨. 강제되지 않은 관찰 기록임" + ), + format!( + "{count} entries were recorded in observe mode; they are unenforced observations" + ) + ) ), - Self::HeadMissing => write!(f, "head.json 없음. 잘라내기를 탐지할 수 없음"), + Self::HeadMissing => f.write_str(tr!( + "head.json 없음. 잘라내기를 탐지할 수 없음", + "head.json missing; truncation cannot be detected" + )), } } } diff --git a/crates/airlock-canonical/Cargo.toml b/crates/airlock-canonical/Cargo.toml index 866e729..dbfd04a 100644 --- a/crates/airlock-canonical/Cargo.toml +++ b/crates/airlock-canonical/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true description = "Airlock 감사 로그와 정책 다이제스트가 쓰는 길이 접두 정규 인코딩" [dependencies] +airlock-i18n.workspace = true [lints] workspace = true diff --git a/crates/airlock-canonical/src/hex.rs b/crates/airlock-canonical/src/hex.rs index 55e51ae..fd9c52a 100644 --- a/crates/airlock-canonical/src/hex.rs +++ b/crates/airlock-canonical/src/hex.rs @@ -1,3 +1,5 @@ +use airlock_i18n::tr; + const HEX: &[u8; 16] = b"0123456789abcdef"; pub fn encode(bytes: &[u8]) -> String { @@ -19,11 +21,20 @@ pub enum HexError { impl core::fmt::Display for HexError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { - Self::OddLength => write!(f, "hex 문자열 길이가 홀수"), - Self::BadDigit => write!(f, "hex가 아닌 문자 포함"), - Self::WrongLength { expected, got } => { - write!(f, "hex 길이 불일치: {expected}바이트 기대, {got}바이트") + Self::OddLength => { + f.write_str(tr!("hex 문자열 길이가 홀수", "hex string has odd length")) + } + Self::BadDigit => { + f.write_str(tr!("hex가 아닌 문자 포함", "contains a non-hex character")) } + Self::WrongLength { expected, got } => write!( + f, + "{}", + tr!( + format!("hex 길이 불일치: {expected}바이트 기대, {got}바이트"), + format!("hex length mismatch: expected {expected} bytes, got {got}") + ) + ), } } } diff --git a/crates/airlock-policy/Cargo.toml b/crates/airlock-policy/Cargo.toml index 7caa09b..5be5aeb 100644 --- a/crates/airlock-policy/Cargo.toml +++ b/crates/airlock-policy/Cargo.toml @@ -9,6 +9,7 @@ description = "Airlock의 capability 정책 모델과 TOML DSL, 평가 엔진" [dependencies] airlock-canonical.workspace = true +airlock-i18n.workspace = true libc.workspace = true serde.workspace = true sha2.workspace = true diff --git a/crates/airlock-policy/src/baseline.rs b/crates/airlock-policy/src/baseline.rs index fd0ac53..9772b98 100644 --- a/crates/airlock-policy/src/baseline.rs +++ b/crates/airlock-policy/src/baseline.rs @@ -1,5 +1,7 @@ use std::path::{Path, PathBuf}; +use airlock_i18n::tr; + use crate::glob::{Pattern, PatternError, TextPattern}; use crate::model::{Action, FileMode, ModeSet, Tier}; use crate::rule::{Matcher, ProgramMatch, Rule}; @@ -323,7 +325,13 @@ pub fn self_protect(paths: &SelfProtectPaths) -> Vec { id: "self:audit-log".to_string(), tier: Tier::SelfProtect, action: Action::Deny, - reason: Some("감사 로그가 기록 대상에게 쓰기 가능하면 증거가 아님".to_string()), + reason: Some( + tr!( + "감사 로그가 기록 대상에게 쓰기 가능하면 증거가 아님", + "an audit log writable by its subject is not evidence" + ) + .to_string(), + ), overrides: None, matcher: Matcher::File { paths: vec![ @@ -341,7 +349,13 @@ pub fn self_protect(paths: &SelfProtectPaths) -> Vec { id: "self:policy-file".to_string(), tier: Tier::SelfProtect, action: Action::Deny, - reason: Some("정책 파일을 대상이 고칠 수 있으면 강제가 아님".to_string()), + reason: Some( + tr!( + "정책 파일을 대상이 고칠 수 있으면 강제가 아님", + "a policy file its subject can edit is not enforcement" + ) + .to_string(), + ), overrides: None, matcher: Matcher::File { paths: paths @@ -359,7 +373,13 @@ pub fn self_protect(paths: &SelfProtectPaths) -> Vec { id: "self:binary".to_string(), tier: Tier::SelfProtect, action: Action::Deny, - reason: Some("브로커 바이너리 교체는 TCB 교체임".to_string()), + reason: Some( + tr!( + "브로커 바이너리 교체는 TCB 교체임", + "replacing the broker binary replaces the TCB" + ) + .to_string(), + ), overrides: None, matcher: Matcher::File { paths: vec![Pattern::literal(binary)], diff --git a/crates/airlock-policy/src/dsl.rs b/crates/airlock-policy/src/dsl.rs index da56869..c9113bd 100644 --- a/crates/airlock-policy/src/dsl.rs +++ b/crates/airlock-policy/src/dsl.rs @@ -1,5 +1,6 @@ use std::path::Path; +use airlock_i18n::tr; use serde::Deserialize; use crate::error::LoadError; @@ -204,7 +205,10 @@ pub fn to_rule(raw: RawRule, home: &Path) -> Result { if program.is_none() && argv_contains.is_empty() && argv_pattern.is_none() { return Err(LoadError::MissingField { id: id.clone(), - field: "program 또는 argv_contains 또는 argv_pattern", + field: tr!( + "program 또는 argv_contains 또는 argv_pattern", + "program or argv_contains or argv_pattern" + ), }); } Matcher::Exec { diff --git a/crates/airlock-policy/src/engine.rs b/crates/airlock-policy/src/engine.rs index 220c20b..d8b596b 100644 --- a/crates/airlock-policy/src/engine.rs +++ b/crates/airlock-policy/src/engine.rs @@ -1,6 +1,8 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; +use airlock_i18n::tr; + use crate::baseline::{self, SelfProtectPaths}; use crate::digest; use crate::dsl; @@ -249,9 +251,16 @@ impl Policy { let reserved: Vec<(&str, &'static str)> = base .rules .iter() - .map(|r| (r.id.as_str(), "베이스라인")) - .chain(self_protect.iter().map(|r| (r.id.as_str(), "자기보호"))) - .chain(std::iter::once((PLAINTEXT_FLOOR_ID, "평문 바닥"))) + .map(|r| (r.id.as_str(), tr!("베이스라인", "baseline"))) + .chain( + self_protect + .iter() + .map(|r| (r.id.as_str(), tr!("자기보호", "self-protect"))), + ) + .chain(std::iter::once(( + PLAINTEXT_FLOOR_ID, + tr!("평문 바닥", "plaintext floor"), + ))) .collect(); let mut seen: HashSet<&str> = HashSet::new(); @@ -676,9 +685,14 @@ fn quota_rule( action, pattern: format!("{host}:{port} [max_bytes_out={limit} used={used}] <- {origin}"), reason: Some( - "이 목적지로 누적 반출한 바이트가 max_bytes_out 을 넘음. 바이트 수는 연결이 끝나야 \ - 알 수 있으므로 한도를 넘긴 그 연결 자체는 막지 못했고 이번 연결부터 막힘" - .to_string(), + tr!( + "이 목적지로 누적 반출한 바이트가 max_bytes_out 을 넘음. 바이트 수는 연결이 끝나야 \ + 알 수 있으므로 한도를 넘긴 그 연결 자체는 막지 못했고 이번 연결부터 막힘", + "cumulative bytes sent to this destination exceeded max_bytes_out; byte counts \ + are only known when a connection ends, so the connection that crossed the limit \ + could not be blocked and blocking starts with this one" + ) + .to_string(), ), } } @@ -709,9 +723,13 @@ fn plaintext_floor_rule( action, pattern, reason: Some( - "평문 아웃바운드는 [defaults].egress_plaintext 가 정한 바닥을 넘지 못함. \ - 열려면 그 규칙에 protocol = \"http\" 를 명시할 것" - .to_string(), + tr!( + "평문 아웃바운드는 [defaults].egress_plaintext 가 정한 바닥을 넘지 못함. \ + 열려면 그 규칙에 protocol = \"http\" 를 명시할 것", + "plaintext outbound cannot pass the floor set by [defaults].egress_plaintext; \ + to open it, state protocol = \"http\" on that rule" + ) + .to_string(), ), } } diff --git a/crates/airlock-policy/src/error.rs b/crates/airlock-policy/src/error.rs index 570a525..becf9c0 100644 --- a/crates/airlock-policy/src/error.rs +++ b/crates/airlock-policy/src/error.rs @@ -1,6 +1,8 @@ use std::fmt; use std::path::PathBuf; +use airlock_i18n::tr; + use crate::glob::PatternError; use crate::host::HostError; use crate::model::Kind; @@ -114,101 +116,277 @@ pub enum LoadError { impl fmt::Display for LoadError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Toml(e) => write!(f, "TOML 파싱 실패: {e}"), - Self::Io { path, source } => write!(f, "{} 읽기 실패: {source}", path.display()), - Self::UnsupportedVersion(v) => { - write!(f, "지원하지 않는 정책 version {v}. v1만 지원함") - } + Self::Toml(e) => write!( + f, + "{}", + tr!(format!("TOML 파싱 실패: {e}"), format!("failed to parse TOML: {e}")) + ), + Self::Io { path, source } => write!( + f, + "{}", + tr!( + format!("{} 읽기 실패: {source}", path.display()), + format!("failed to read {}: {source}", path.display()) + ) + ), + Self::UnsupportedVersion(v) => write!( + f, + "{}", + tr!( + format!("지원하지 않는 정책 version {v}. v1만 지원함"), + format!("unsupported policy version {v}; only v1 is supported") + ) + ), Self::UntrustedFile { path, why } => write!( f, - "{} 를 신뢰할 수 없음: {why}. 정책 파일은 신뢰 경계 전체를 정의하므로 \ + "{}", + tr!( + format!( + "{} 를 신뢰할 수 없음: {why}. 정책 파일은 신뢰 경계 전체를 정의하므로 \ 호출자 소유이고 남이 쓸 수 없어야 함", - path.display() + path.display() + ), + format!( + "cannot trust {}: {why}; the policy file defines the whole trust \ + boundary, so it must be owned by the caller and writable by no one else", + path.display() + ) + ) + ), + Self::DuplicateId(id) => write!( + f, + "{}", + tr!( + format!("규칙 id `{id}`가 중복됨"), + format!("rule id `{id}` is duplicated") + ) ), - Self::DuplicateId(id) => write!(f, "규칙 id `{id}`가 중복됨"), Self::InvalidId { id, offender } => write!( f, - "규칙 id `{}`에 쓸 수 없는 문자 {:?}가 있음. \ + "{}", + tr!( + format!( + "규칙 id `{}`에 쓸 수 없는 문자 {:?}가 있음. \ id는 커널 프로파일에 주석으로 들어가므로 영숫자와 . _ - : 만 허용함", - id.escape_debug(), - offender + id.escape_debug(), + offender + ), + format!( + "rule id `{}` contains a disallowed character {:?}; ids go into the \ + kernel profile as comments, so only alphanumerics and . _ - : are allowed", + id.escape_debug(), + offender + ) + ) ), Self::ReservedId { id, tier } => write!( f, - "규칙 id `{id}`가 내장 {tier} 규칙과 겹침. 감사 로그의 rule 필드가 \ + "{}", + tr!( + format!( + "규칙 id `{id}`가 내장 {tier} 규칙과 겹침. 감사 로그의 rule 필드가 \ 어느 티어를 가리키는지 알 수 없게 되므로 다른 id를 쓸 것. \ 내장 규칙을 완화하려면 id 대신 overrides로 지목할 것" + ), + format!( + "rule id `{id}` collides with the built-in {tier} rule; the audit log's \ + rule field would no longer say which tier it points to, so use a different id. \ + To relax a built-in rule, name it with overrides instead" + ) + ) ), Self::ReservedNamespace { id } => write!( f, - "규칙 id `{id}`가 `airlock:` 이름 공간을 씀. 그 접두는 엔진이 만드는 합성 규칙 \ + "{}", + tr!( + format!( + "규칙 id `{id}`가 `airlock:` 이름 공간을 씀. 그 접두는 엔진이 만드는 합성 규칙 \ 전용이라 사용자 규칙이 쓰면 감사 로그의 rule 필드가 무엇을 가리키는지 알 수 \ 없어짐. 다른 id를 쓸 것" + ), + format!( + "rule id `{id}` uses the `airlock:` namespace; that prefix is reserved for \ + synthetic rules the engine creates, and a user rule taking it leaves the audit \ + log's rule field ambiguous. Use a different id" + ) + ) ), Self::QuotaOnBlockingRule { id } => write!( f, - "`{id}`는 이미 막는 규칙인데 max_bytes_out을 적었음. 총량 한도는 통과시키는 \ + "{}", + tr!( + format!( + "`{id}`는 이미 막는 규칙인데 max_bytes_out을 적었음. 총량 한도는 통과시키는 \ 규칙에만 뜻이 있으며, 적어 두면 총량 제한이 걸렸다고 잘못 믿게 됨" + ), + format!( + "`{id}` already blocks yet declares max_bytes_out; a byte quota only means \ + something on a rule that lets traffic through, and writing it here invites the \ + false belief that a quota is enforced" + ) + ) ), Self::UnknownAction { id, value } => write!( f, - "`{id}`의 action `{value}`를 알 수 없음. allow, deny, ask, forbid 중 하나여야 함" + "{}", + tr!( + format!( + "`{id}`의 action `{value}`를 알 수 없음. allow, deny, ask, forbid 중 하나여야 함" + ), + format!( + "unknown action `{value}` in `{id}`; must be one of allow, deny, ask, forbid" + ) + ) ), Self::UnknownKind { id, value } => write!( f, - "`{id}`의 kind `{value}`를 알 수 없음. file, exec, egress 중 하나여야 함" + "{}", + tr!( + format!( + "`{id}`의 kind `{value}`를 알 수 없음. file, exec, egress 중 하나여야 함" + ), + format!("unknown kind `{value}` in `{id}`; must be one of file, exec, egress") + ) ), Self::UnknownMode { id, value } => write!( f, - "`{id}`의 mode `{value}`를 알 수 없음. read, write, create, delete, metadata, exec 중 하나여야 함" + "{}", + tr!( + format!( + "`{id}`의 mode `{value}`를 알 수 없음. read, write, create, delete, metadata, exec 중 하나여야 함" + ), + format!( + "unknown mode `{value}` in `{id}`; must be one of read, write, create, delete, metadata, exec" + ) + ) ), Self::UnknownProtocol { id, value } => write!( f, - "`{id}`의 protocol `{value}`를 알 수 없음. tcp, tls, http 중 하나여야 함" + "{}", + tr!( + format!( + "`{id}`의 protocol `{value}`를 알 수 없음. tcp, tls, http 중 하나여야 함" + ), + format!("unknown protocol `{value}` in `{id}`; must be one of tcp, tls, http") + ) + ), + Self::MissingField { id, field } => write!( + f, + "{}", + tr!( + format!("`{id}`에 필수 필드 `{field}`가 없음"), + format!("`{id}` is missing the required field `{field}`") + ) + ), + Self::UnexpectedField { id, field, kind } => write!( + f, + "{}", + tr!( + format!("`{id}`는 kind가 {kind}인데 `{field}` 필드를 가짐"), + format!("`{id}` has kind {kind} but carries the `{field}` field") + ) ), - Self::MissingField { id, field } => { - write!(f, "`{id}`에 필수 필드 `{field}`가 없음") - } - Self::UnexpectedField { id, field, kind } => { - write!(f, "`{id}`는 kind가 {kind}인데 `{field}` 필드를 가짐") - } - Self::EmptyModeSet { id } => { - write!( - f, - "`{id}`의 mode가 빈 배열임. 아무것도 매칭하지 않는 규칙은 오류임" + Self::EmptyModeSet { id } => write!( + f, + "{}", + tr!( + format!("`{id}`의 mode가 빈 배열임. 아무것도 매칭하지 않는 규칙은 오류임"), + format!("`{id}` has an empty mode array; a rule that matches nothing is an error") ) - } - Self::Pattern { id, source } => write!(f, "`{id}` 패턴 오류: {source}"), - Self::Host { id, source } => write!(f, "`{id}` 호스트 오류: {source}"), - Self::EgressDefaultAllow => write!( + ), + Self::Pattern { id, source } => write!( f, - "[defaults].egress는 allow가 될 수 없음. 아웃바운드 기본 허용은 데이터 반출 방어를 포기하는 설정임" + "{}", + tr!( + format!("`{id}` 패턴 오류: {source}"), + format!("`{id}` pattern error: {source}") + ) + ), + Self::Host { id, source } => write!( + f, + "{}", + tr!( + format!("`{id}` 호스트 오류: {source}"), + format!("`{id}` host error: {source}") + ) ), + Self::EgressDefaultAllow => f.write_str(tr!( + "[defaults].egress는 allow가 될 수 없음. 아웃바운드 기본 허용은 데이터 반출 방어를 포기하는 설정임", + "[defaults].egress cannot be allow; allowing outbound by default abandons exfiltration defense" + )), Self::ForbidDefault { kind } => write!( f, - "[defaults].{kind}는 forbid가 될 수 없음. forbid는 내장 베이스라인 전용임" + "{}", + tr!( + format!("[defaults].{kind}는 forbid가 될 수 없음. forbid는 내장 베이스라인 전용임"), + format!( + "[defaults].{kind} cannot be forbid; forbid is reserved for the built-in baseline" + ) + ) ), Self::ForbidInUserRule { id } => write!( f, - "`{id}`의 action이 forbid임. forbid는 내장 베이스라인 전용이며 사용자 규칙은 allow, deny, ask 중 하나를 씀" + "{}", + tr!( + format!( + "`{id}`의 action이 forbid임. forbid는 내장 베이스라인 전용이며 사용자 규칙은 allow, deny, ask 중 하나를 씀" + ), + format!( + "`{id}` has action forbid; forbid is reserved for the built-in baseline, and user rules use one of allow, deny, ask" + ) + ) ), Self::EmptyOverrideTarget { id } => write!( f, - "`{id}`의 overrides가 빈 문자열임. 완화 대상을 지목하지 않을 거면 필드를 빼야 함" + "{}", + tr!( + format!( + "`{id}`의 overrides가 빈 문자열임. 완화 대상을 지목하지 않을 거면 필드를 빼야 함" + ), + format!( + "`{id}` has an empty overrides string; if no relaxation target is named, drop the field" + ) + ) ), Self::WildcardHostAllow { id } => write!( f, - "`{id}`는 host = \"*\"를 allow함. 아웃바운드 전면 허용은 [defaults].egress = \"allow\"와 같으며 데이터 반출 방어를 포기하는 설정임" + "{}", + tr!( + format!( + "`{id}`는 host = \"*\"를 allow함. 아웃바운드 전면 허용은 [defaults].egress = \"allow\"와 같으며 데이터 반출 방어를 포기하는 설정임" + ), + format!( + "`{id}` allows host = \"*\"; allowing all outbound equals [defaults].egress = \"allow\" and abandons exfiltration defense" + ) + ) + ), + Self::UnknownOverrideTarget { id, target } => write!( + f, + "{}", + tr!( + format!("`{id}`의 overrides 대상 `{target}`가 내장 규칙에 없음"), + format!("overrides target `{target}` of `{id}` is not a built-in rule") + ) + ), + Self::OverrideTargetNotForbid { id, target } => write!( + f, + "{}", + tr!( + format!("`{id}`의 overrides 대상 `{target}`는 forbid 규칙이 아님"), + format!("overrides target `{target}` of `{id}` is not a forbid rule") + ) ), - Self::UnknownOverrideTarget { id, target } => { - write!(f, "`{id}`의 overrides 대상 `{target}`가 내장 규칙에 없음") - } - Self::OverrideTargetNotForbid { id, target } => { - write!(f, "`{id}`의 overrides 대상 `{target}`는 forbid 규칙이 아님") - } Self::OverrideWithoutReason { id } => write!( f, - "`{id}`는 overrides를 쓰면서 reason이 비어 있음. 시크릿 보호 완화는 근거 없이 허용하지 않음" + "{}", + tr!( + format!( + "`{id}`는 overrides를 쓰면서 reason이 비어 있음. 시크릿 보호 완화는 근거 없이 허용하지 않음" + ), + format!( + "`{id}` uses overrides but reason is empty; relaxing secret protection is not allowed without a stated reason" + ) + ) ), } } @@ -254,42 +432,95 @@ impl fmt::Display for LoadWarning { match self { Self::ShadowedRule { id, by } => write!( f, - "`{id}`는 앞선 규칙 `{by}`에 완전히 가려져 도달할 수 없음" + "{}", + tr!( + format!("`{id}`는 앞선 규칙 `{by}`에 완전히 가려져 도달할 수 없음"), + format!( + "`{id}` is fully shadowed by the earlier rule `{by}` and can never be reached" + ) + ) ), Self::UnusedOverride { id, target } => write!( f, - "`{id}`의 overrides = \"{target}\"는 실제로 아무 보호도 완화하지 않음" + "{}", + tr!( + format!("`{id}`의 overrides = \"{target}\"는 실제로 아무 보호도 완화하지 않음"), + format!( + "overrides = \"{target}\" on `{id}` does not actually relax any protection" + ) + ) ), Self::HostRuleNeedsProxy { id } => write!( f, - "`{id}`는 호스트 단위 egress 규칙임. `airlock run --egress-proxy`로 실행할 때만 \ + "{}", + tr!( + format!( + "`{id}`는 호스트 단위 egress 규칙임. `airlock run --egress-proxy`로 실행할 때만 \ 강제되고, 그냥 실행하면 커널이 판정하지 못함" + ), + format!( + "`{id}` is a per-host egress rule; it is only enforced under \ + `airlock run --egress-proxy`, and a plain run leaves the kernel unable to judge it" + ) + ) ), Self::ProtocolRuleNeedsProxy { id } => write!( f, - "`{id}`는 protocol을 지정한 egress 규칙임. 중계 층은 connect(2)만 보고 모든 연결을 \ + "{}", + tr!( + format!( + "`{id}`는 protocol을 지정한 egress 규칙임. 중계 층은 connect(2)만 보고 모든 연결을 \ tcp로 보고하므로, `airlock run --egress-proxy` 없이는 tls와 http 규칙이 \ 아무것도 매칭하지 않고 평문 바닥도 발동하지 않음" + ), + format!( + "`{id}` is an egress rule with a protocol condition; the mediation layer sees \ + only connect(2) and reports every connection as tcp, so without \ + `airlock run --egress-proxy` tls and http rules match nothing and the plaintext \ + floor never fires" + ) + ) ), Self::QuotaRuleNeedsProxy { id } => write!( f, - "`{id}`는 max_bytes_out을 적은 egress 규칙임. 반출 바이트는 프록시 층만 세므로 \ + "{}", + tr!( + format!( + "`{id}`는 max_bytes_out을 적은 egress 규칙임. 반출 바이트는 프록시 층만 세므로 \ `airlock run --egress-proxy` 없이는 누적량이 늘 0이고 한도가 한 번도 걸리지 않음" + ), + format!( + "`{id}` is an egress rule with max_bytes_out; only the proxy layer counts \ + outbound bytes, so without `airlock run --egress-proxy` the running total stays 0 \ + and the limit never triggers" + ) + ) ), - Self::PlaintextEgressAllowed => write!( - f, + Self::PlaintextEgressAllowed => f.write_str(tr!( "[defaults].egress_plaintext = \"allow\"는 평문 아웃바운드 바닥을 없앰. \ 허용된 호스트로 나가는 본문이 경로 전체에 그대로 노출됨. \ - 특정 호스트만 열려면 그 규칙에 protocol = \"http\"를 적을 것" - ), + 특정 호스트만 열려면 그 규칙에 protocol = \"http\"를 적을 것", + "[defaults].egress_plaintext = \"allow\" removes the plaintext outbound floor; \ + bodies sent to allowed hosts are exposed along the whole path. To open only \ + specific hosts, write protocol = \"http\" on those rules" + )), Self::IneffectiveRelaxation { id, forbid_id, probe, } => write!( f, - "`{id}`는 {}를 열려고 하지만 forbid 규칙 `{forbid_id}`가 상위 tier에서 이김. 실제로 완화하려면 overrides = \"{forbid_id}\"와 reason을 명시해야 함", - probe.display() + "{}", + tr!( + format!( + "`{id}`는 {}를 열려고 하지만 forbid 규칙 `{forbid_id}`가 상위 tier에서 이김. 실제로 완화하려면 overrides = \"{forbid_id}\"와 reason을 명시해야 함", + probe.display() + ), + format!( + "`{id}` tries to open {} but the forbid rule `{forbid_id}` wins at a higher tier; to actually relax it, state overrides = \"{forbid_id}\" and a reason", + probe.display() + ) + ) ), } } diff --git a/crates/airlock-policy/src/glob.rs b/crates/airlock-policy/src/glob.rs index 285a8ef..68e03d9 100644 --- a/crates/airlock-policy/src/glob.rs +++ b/crates/airlock-policy/src/glob.rs @@ -2,6 +2,7 @@ use std::fmt; use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::path::{Path, PathBuf}; +use airlock_i18n::tr; use unicode_normalization::UnicodeNormalization; const MAX_DOUBLE_STARS: usize = 4; @@ -19,20 +20,47 @@ pub enum PatternError { impl fmt::Display for PatternError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Empty => write!(f, "빈 패턴"), + Self::Empty => f.write_str(tr!("빈 패턴", "empty pattern")), Self::NotAbsolute(p) => write!( f, - "`{p}`는 절대 경로가 아님. `/`, `~/`, `**/` 중 하나로 시작해야 함" + "{}", + tr!( + format!("`{p}`는 절대 경로가 아님. `/`, `~/`, `**/` 중 하나로 시작해야 함"), + format!("`{p}` is not absolute; it must start with one of `/`, `~/`, `**/`") + ) + ), + Self::UserHome(p) => write!( + f, + "{}", + tr!( + format!("`{p}`의 `~user` 형태는 v1에서 지원하지 않음"), + format!("the `~user` form in `{p}` is not supported in v1") + ) + ), + Self::DotSegment(p) => write!( + f, + "{}", + tr!( + format!("`{p}`에 `.` 또는 `..` 세그먼트가 있음"), + format!("`{p}` contains a `.` or `..` segment") + ) ), - Self::UserHome(p) => write!(f, "`{p}`의 `~user` 형태는 v1에서 지원하지 않음"), - Self::DotSegment(p) => write!(f, "`{p}`에 `.` 또는 `..` 세그먼트가 있음"), Self::EmbeddedDoubleStar(p) => write!( f, - "`{p}`의 `**`는 세그먼트 전체여야 함. 세그먼트 일부로 쓸 수 없음" + "{}", + tr!( + format!("`{p}`의 `**`는 세그먼트 전체여야 함. 세그먼트 일부로 쓸 수 없음"), + format!("`**` in `{p}` must be a whole segment; it cannot be part of one") + ) + ), + Self::TooManyDoubleStars(p) => write!( + f, + "{}", + tr!( + format!("`{p}`의 `**`가 {MAX_DOUBLE_STARS}개를 넘음"), + format!("`{p}` has more than {MAX_DOUBLE_STARS} `**` segments") + ) ), - Self::TooManyDoubleStars(p) => { - write!(f, "`{p}`의 `**`가 {MAX_DOUBLE_STARS}개를 넘음") - } } } } diff --git a/crates/airlock-policy/src/host.rs b/crates/airlock-policy/src/host.rs index 8d15035..87bc3bb 100644 --- a/crates/airlock-policy/src/host.rs +++ b/crates/airlock-policy/src/host.rs @@ -1,6 +1,8 @@ use std::fmt; use std::net::IpAddr; +use airlock_i18n::tr; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum HostError { Empty, @@ -12,15 +14,36 @@ pub enum HostError { impl fmt::Display for HostError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Empty => write!(f, "빈 호스트 패턴"), + Self::Empty => f.write_str(tr!("빈 호스트 패턴", "empty host pattern")), Self::NonAscii(h) => write!( f, - "`{h}`는 비ASCII 호스트임. v1은 IDN punycode 변환을 하지 않으므로 punycode로 직접 적어야 함" + "{}", + tr!( + format!( + "`{h}`는 비ASCII 호스트임. v1은 IDN punycode 변환을 하지 않으므로 punycode로 직접 적어야 함" + ), + format!( + "`{h}` is a non-ASCII host; v1 does not do IDN punycode conversion, so write it in punycode directly" + ) + ) + ), + Self::BadChars(h) => write!( + f, + "{}", + tr!( + format!("`{h}`에 호스트명에 쓸 수 없는 문자가 있음"), + format!("`{h}` contains characters not allowed in a hostname") + ) ), - Self::BadChars(h) => write!(f, "`{h}`에 호스트명에 쓸 수 없는 문자가 있음"), Self::BareWildcardLabel(h) => write!( f, - "`{h}` 형태는 지원하지 않음. `*`는 전체 또는 선행 `*.` 형태로만 씀" + "{}", + tr!( + format!("`{h}` 형태는 지원하지 않음. `*`는 전체 또는 선행 `*.` 형태로만 씀"), + format!( + "the `{h}` form is not supported; `*` is used only as the whole pattern or as a leading `*.`" + ) + ) ), } } diff --git a/crates/airlock-policy/src/path.rs b/crates/airlock-policy/src/path.rs index bf5cd73..2775e23 100644 --- a/crates/airlock-policy/src/path.rs +++ b/crates/airlock-policy/src/path.rs @@ -2,6 +2,8 @@ use std::ffi::{OsStr, OsString}; use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::path::{Path, PathBuf}; +use airlock_i18n::tr; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct NormalizedPath { pub requested: PathBuf, @@ -68,16 +70,25 @@ pub fn read_trusted(path: &Path) -> Result { if meta.uid() != uid { return Err(crate::error::LoadError::UntrustedFile { path: path.to_path_buf(), - why: format!("uid {}의 소유임. 호출자는 uid {uid}", meta.uid()), + why: tr!( + format!("uid {}의 소유임. 호출자는 uid {uid}", meta.uid()), + format!("owned by uid {}; the caller is uid {uid}", meta.uid()) + ), }); } // 022. 그룹이나 그 밖의 사용자가 쓸 수 있으면 그들이 곧 정책 작성자입니다 if meta.mode() & 0o022 != 0 { return Err(crate::error::LoadError::UntrustedFile { path: path.to_path_buf(), - why: format!( - "권한이 {:04o}로 다른 사용자가 쓸 수 있음", - meta.mode() & 0o7777 + why: tr!( + format!( + "권한이 {:04o}로 다른 사용자가 쓸 수 있음", + meta.mode() & 0o7777 + ), + format!( + "mode {:04o} lets other users write to it", + meta.mode() & 0o7777 + ) ), }); } From bf4c09ee716b3229fb7cf99a6ff1e84781cd8519 Mon Sep 17 00:00:00 2001 From: Quant-TheodoreFelix <53819958+Quant-TheodoreFelix@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:48:55 +0900 Subject: [PATCH 4/6] =?UTF-8?q?CLI=20=EC=B6=9C=EB=A0=A5=EC=9D=84=20?= =?UTF-8?q?=EB=A1=9C=EC=BC=80=EC=9D=BC=EC=97=90=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도움말과 배너와 한계 목록과 세션 요약과 감사 보고를 두 언어로 냄 - `audit report --json` 에 `locale` 필드를 넣어 어느 로케일로 만든 보고인지 남김 - 통합 테스트는 `AIRLOCK_LANG=ko` 로 고정해 호스트 로케일에 흔들리지 않음 Co-authored-by: Claude Fable 5 --- crates/airlock/Cargo.toml | 1 + crates/airlock/src/cmd_audit.rs | 563 +++++++++++++++++++++++++------ crates/airlock/src/cmd_policy.rs | 334 +++++++++++++++--- crates/airlock/src/cmd_run.rs | 483 +++++++++++++++++++++----- crates/airlock/src/cmd_setup.rs | 14 +- crates/airlock/src/main.rs | 39 ++- crates/airlock/src/render.rs | 527 +++++++++++++++++++++++------ crates/airlock/src/report.rs | 97 ++++-- crates/airlock/tests/cli.rs | 1 + 9 files changed, 1689 insertions(+), 370 deletions(-) diff --git a/crates/airlock/Cargo.toml b/crates/airlock/Cargo.toml index d9cca85..4139115 100644 --- a/crates/airlock/Cargo.toml +++ b/crates/airlock/Cargo.toml @@ -10,6 +10,7 @@ description = "AI 코딩 에이전트를 위한 로컬 제로트러스트 게이 [dependencies] airlock-audit.workspace = true airlock-canonical.workspace = true +airlock-i18n.workspace = true airlock-broker.workspace = true airlock-policy.workspace = true airlock-proxy.workspace = true diff --git a/crates/airlock/src/cmd_audit.rs b/crates/airlock/src/cmd_audit.rs index dc8768f..fe81708 100644 --- a/crates/airlock/src/cmd_audit.rs +++ b/crates/airlock/src/cmd_audit.rs @@ -5,6 +5,7 @@ use airlock_audit::{ verify_dir, }; use airlock_canonical::display::sanitize; +use airlock_i18n::tr; use crate::paths; use crate::render; @@ -12,70 +13,133 @@ use crate::report::{Report, range_of}; #[derive(Debug, clap::Subcommand)] pub enum AuditCommand { - #[command(about = "해시체인 무결성을 검증함")] + #[command(about = tr!("해시체인 무결성을 검증함", "verify hash chain integrity"))] Verify { - #[arg(value_name = "DIR", help = "세션 디렉토리. 생략하면 가장 최근 세션")] + #[arg( + value_name = "DIR", + help = tr!( + "세션 디렉토리. 생략하면 가장 최근 세션", + "session directory; defaults to the most recent session" + ) + )] dir: Option, - #[arg(long, help = "모든 세션을 검증함")] + #[arg(long, help = tr!("모든 세션을 검증함", "verify every session"))] all: bool, #[arg( long, value_name = "DIR", - help = "세션 상위 앵커(anchors.jsonl)가 있는 디렉토리. \ + help = tr!( + "세션 상위 앵커(anchors.jsonl)가 있는 디렉토리. \ airlock run --anchor-dir 로 분리했다면 같은 값을 넘겨야 함. \ - 생략하면 감사 루트" + 생략하면 감사 루트", + "directory holding the session anchors (anchors.jsonl); if it was \ + separated with airlock run --anchor-dir, pass the same value; \ + defaults to the audit root" + ) )] anchor_dir: Option, }, - #[command(about = "감사 엔트리를 사람이 읽는 형태로 출력함")] + #[command( + about = tr!( + "감사 엔트리를 사람이 읽는 형태로 출력함", + "print audit entries in a human-readable form" + ) + )] Show { #[arg(value_name = "DIR")] dir: Option, - #[arg(long, default_value_t = 50, help = "출력할 최대 엔트리 수")] + #[arg( + long, + default_value_t = 50, + help = tr!("출력할 최대 엔트리 수", "maximum number of entries to print") + )] limit: usize, - #[arg(long, help = "차단과 승인 요청만 보여줌")] + #[arg( + long, + help = tr!("차단과 승인 요청만 보여줌", "show only denials and asks") + )] decisions_only: bool, }, - #[command(about = "세션 목록을 보여줌")] + #[command(about = tr!("세션 목록을 보여줌", "list sessions"))] List, #[command( - about = "매일 이상여부 점검 보고를 만듦", - long_about = "범위 안의 모든 세션을 검증하고 결정·승인·아웃바운드를 집계함. \ + about = tr!( + "매일 이상여부 점검 보고를 만듦", + "produce the daily anomaly check report" + ), + long_about = tr!( + "범위 안의 모든 세션을 검증하고 결정·승인·아웃바운드를 집계함. \ 이상이 하나라도 있으면 종료 코드가 비영이므로 cron 이나 launchd 에 그대로 걸 수 있음. \ 종료 코드는 0 이상 없음, 2 증거 이상(무결성·앵커·읽기 실패), 3 운영 이상(미응답 ask 등), \ -64 인자 오류, 70 내부 오류임" +64 인자 오류, 70 내부 오류임", + "verifies every session in range and aggregates decisions, approvals, and \ +outbound traffic. any anomaly makes the exit code non-zero, so it can be wired into \ +cron or launchd as-is. exit codes: 0 no anomalies, 2 evidence anomaly (integrity, \ +anchors, read failure), 3 operational anomaly (unanswered ask and the like), 64 usage \ +error, 70 internal error" + ) )] Report { - #[arg(long, value_name = "YYYY-MM-DD", help = "이 날짜 00:00:00 UTC 부터")] + #[arg( + long, + value_name = "YYYY-MM-DD", + help = tr!("이 날짜 00:00:00 UTC 부터", "from 00:00:00 UTC on this date") + )] since: Option, #[arg( long, value_name = "YYYY-MM-DD", - help = "이 날짜 23:59:59 UTC 까지 (그 날을 통째로 포함함)" + help = tr!( + "이 날짜 23:59:59 UTC 까지 (그 날을 통째로 포함함)", + "until 23:59:59 UTC on this date (covers that whole day)" + ) )] until: Option, - #[arg(long, help = "SIEM 과 스크립트가 먹을 수 있는 JSON 으로 출력함")] + #[arg( + long, + help = tr!( + "SIEM 과 스크립트가 먹을 수 있는 JSON 으로 출력함", + "print JSON that SIEMs and scripts can consume" + ) + )] json: bool, #[arg( long, value_name = "DIR", - help = "앵커(anchors.jsonl)와 확인 기록(reviews.jsonl)이 있는 디렉토리. \ - 생략하면 감사 루트" + help = tr!( + "앵커(anchors.jsonl)와 확인 기록(reviews.jsonl)이 있는 디렉토리. \ + 생략하면 감사 루트", + "directory holding the anchors (anchors.jsonl) and the review records \ + (reviews.jsonl); defaults to the audit root" + ) )] anchor_dir: Option, #[arg( long, - help = "사람 신원 없는 자동 승인을 이상으로 셈. 기본값은 표시만 함" + help = tr!( + "사람 신원 없는 자동 승인을 이상으로 셈. 기본값은 표시만 함", + "count automatic approvals without a human identity as anomalies; by \ + default they are only displayed" + ) )] strict_approval: bool, }, #[command( - about = "책임자가 점검했다는 사실을 기록함", - long_about = "리포트를 다시 계산해 그 범위와 다이제스트를 확인 체인에 남김. \ + about = tr!( + "책임자가 점검했다는 사실을 기록함", + "record that a responsible person reviewed the report" + ), + long_about = tr!( + "리포트를 다시 계산해 그 범위와 다이제스트를 확인 체인에 남김. \ 확인자 uid 와 euid 는 커널에서 직접 읽고 터미널은 관측된 값만 남김. \ -종료 코드는 리포트와 같으며 기록 자체가 실패하면 70 임" +종료 코드는 리포트와 같으며 기록 자체가 실패하면 70 임", + "recomputes the report and records its range and digest in the review chain. \ +the reviewer uid and euid are read straight from the kernel and the terminal is \ +recorded only as observed. the exit code matches the report; 70 if the recording \ +itself fails" + ) )] Ack { #[arg(long, value_name = "YYYY-MM-DD")] @@ -84,9 +148,19 @@ pub enum AuditCommand { until: Option, #[arg(long, value_name = "DIR")] anchor_dir: Option, - #[arg(long, value_name = "TEXT", help = "확인자가 남기는 메모")] + #[arg( + long, + value_name = "TEXT", + help = tr!("확인자가 남기는 메모", "note left by the reviewer") + )] note: Option, - #[arg(long, help = "자동 승인을 이상으로 세고 그 판정을 기록함")] + #[arg( + long, + help = tr!( + "자동 승인을 이상으로 세고 그 판정을 기록함", + "count automatic approvals as anomalies and record that verdict" + ) + )] strict_approval: bool, }, } @@ -104,7 +178,13 @@ pub fn exec(cmd: AuditCommand, audit_root_override: Option) -> i32 { if all { let sessions = paths::all_sessions(&root); if sessions.is_empty() { - eprintln!("airlock: {}에 세션이 없음", root.display()); + eprintln!( + "{}", + tr!( + format!("airlock: {}에 세션이 없음", root.display()), + format!("airlock: no sessions under {}", root.display()) + ) + ); return 1; } let mut worst = chain; @@ -175,7 +255,13 @@ fn report( Ok(text) => println!("{text}"), Err(e) => { // 직렬화가 실패하면 보고가 없는 것이므로 통과로 끝내지 않습니다 - eprintln!("airlock: 보고를 JSON 으로 만들지 못함: {e}"); + eprintln!( + "{}", + tr!( + format!("airlock: 보고를 JSON 으로 만들지 못함: {e}"), + format!("airlock: failed to render the report as JSON: {e}") + ) + ); return 70; } } @@ -220,14 +306,26 @@ fn ack( let mut log = match ReviewLog::open(&anchors) { Ok(l) => l, Err(e) => { - eprintln!("airlock: 확인 기록을 열지 못함: {e}"); + eprintln!( + "{}", + tr!( + format!("airlock: 확인 기록을 열지 못함: {e}"), + format!("airlock: failed to open the review record: {e}") + ) + ); return 70; } }; let entry = match log.append(&subject, verdict, note.as_deref()) { Ok(e) => e, Err(e) => { - eprintln!("airlock: 확인 기록을 남기지 못함: {e}"); + eprintln!( + "{}", + tr!( + format!("airlock: 확인 기록을 남기지 못함: {e}"), + format!("airlock: failed to append the review record: {e}") + ) + ); return 70; } }; @@ -235,40 +333,105 @@ fn ack( // 방금 쓴 줄이 정말 이 범위와 이 리포트를 가리키는지 다시 봅니다. 기록과 대상이 // 어긋난 채로 성공을 보고하면 그 도장이 알리바이가 됩니다 if let Err(why) = airlock_audit::check_review(&entry, &subject) { - eprintln!("airlock: 확인 기록이 리포트와 어긋남: {why}"); + eprintln!( + "{}", + tr!( + format!("airlock: 확인 기록이 리포트와 어긋남: {why}"), + format!("airlock: the review record does not match the report: {why}") + ) + ); return 70; } let tty = match &entry.reviewer_tty { Some(t) => sanitize(t), - None => "\x1b[33m미관측\x1b[0m".to_string(), + None => tr!("\x1b[33m미관측\x1b[0m", "\x1b[33munobserved\x1b[0m").to_string(), + }; + println!( + "{}", + tr!( + format!( + "\x1b[1;36mairlock\x1b[0m 확인 기록 seq {} {}", + entry.seq, + log.path().display() + ), + format!( + "\x1b[1;36mairlock\x1b[0m review record seq {} {}", + entry.seq, + log.path().display() + ) + ) + ); + println!( + "{}", + tr!( + format!( + " 확인자 uid={} euid={} tty={tty}", + entry.reviewer_uid, entry.reviewer_euid + ), + format!( + " reviewer uid={} euid={} tty={tty}", + entry.reviewer_uid, entry.reviewer_euid + ) + ) + ); + println!( + "{}", + tr!( + format!(" 시각 {}", render::short_time(entry.ts)), + format!(" time {}", render::short_time(entry.ts)) + ) + ); + let range_text = match (&entry.since, &entry.until) { + (None, None) => tr!("전체", "all").to_string(), + (Some(a), None) => tr!( + format!("{} 이후", sanitize(a)), + format!("since {}", sanitize(a)) + ), + (None, Some(b)) => tr!( + format!("{} 까지", sanitize(b)), + format!("until {}", sanitize(b)) + ), + (Some(a), Some(b)) => format!("{} .. {}", sanitize(a), sanitize(b)), }; println!( - "\x1b[1;36mairlock\x1b[0m 확인 기록 seq {} {}", - entry.seq, - log.path().display() + "{}", + tr!( + format!(" 범위 {} 세션 {}개", range_text, entry.sessions.len()), + format!( + " range {} ({} sessions)", + range_text, + entry.sessions.len() + ) + ) ); println!( - " 확인자 uid={} euid={} tty={tty}", - entry.reviewer_uid, entry.reviewer_euid + "{}", + tr!( + format!(" 다이제스트 {}", entry.report_digest), + format!(" digest {}", entry.report_digest) + ) ); - println!(" 시각 {}", render::short_time(entry.ts)); println!( - " 범위 {} 세션 {}개", - match (&entry.since, &entry.until) { - (None, None) => "전체".to_string(), - (Some(a), None) => format!("{} 이후", sanitize(a)), - (None, Some(b)) => format!("{} 까지", sanitize(b)), - (Some(a), Some(b)) => format!("{} .. {}", sanitize(a), sanitize(b)), - }, - entry.sessions.len() + "{}", + tr!( + format!(" 판정 {}", verdict.label()), + format!(" verdict {}", verdict.label()) + ) ); - println!(" 다이제스트 {}", entry.report_digest); - println!(" 판정 {}", verdict.label()); if !built.anomalies.is_empty() { println!( - " \x1b[1;31m이상 {}건\x1b[0m 확인 기록에 이상으로 남았음. airlock audit report 로 내용을 볼 것", - built.anomalies.len() + "{}", + tr!( + format!( + " \x1b[1;31m이상 {}건\x1b[0m 확인 기록에 이상으로 남았음. airlock audit report 로 내용을 볼 것", + built.anomalies.len() + ), + format!( + " \x1b[1;31m{} anomalies\x1b[0m recorded as anomalous in the review record; see airlock audit report for details", + built.anomalies.len() + ) + ) ); } built.exit_code() @@ -280,7 +443,13 @@ fn resolve_dir(dir: Option, root: &Path) -> Option { None => match paths::latest_session(root) { Some(d) => Some(d), None => { - eprintln!("airlock: {}에 세션이 없음", root.display()); + eprintln!( + "{}", + tr!( + format!("airlock: {}에 세션이 없음", root.display()), + format!("airlock: no sessions under {}", root.display()) + ) + ); None } }, @@ -300,28 +469,68 @@ fn report_anchor_chain(anchors: &Path) -> i32 { match verify_anchors(anchors) { Ok(report) => { println!( - "\x1b[32m앵커 확인\x1b[0m {} ({} 줄, 세션 {}개, head seq {})", - anchors.join(airlock_audit::ANCHOR_FILE).display(), - report.entries, - report.sessions, - report.head_seq + "{}", + tr!( + format!( + "\x1b[32m앵커 확인\x1b[0m {} ({} 줄, 세션 {}개, head seq {})", + anchors.join(airlock_audit::ANCHOR_FILE).display(), + report.entries, + report.sessions, + report.head_seq + ), + format!( + "\x1b[32manchors ok\x1b[0m {} ({} lines, {} sessions, head seq {})", + anchors.join(airlock_audit::ANCHOR_FILE).display(), + report.entries, + report.sessions, + report.head_seq + ) + ) ); for w in &report.warnings { - println!(" \x1b[33m경고\x1b[0m {w}"); + println!( + "{}", + tr!( + format!(" \x1b[33m경고\x1b[0m {w}"), + format!(" \x1b[33mwarning\x1b[0m {w}") + ) + ); } 0 } Err(AnchorFailure::FileAbsent) => { println!( - "\x1b[33m앵커 탐지불가\x1b[0m {}", - anchors.join(airlock_audit::ANCHOR_FILE).display() + "{}", + tr!( + format!( + "\x1b[33m앵커 탐지불가\x1b[0m {}", + anchors.join(airlock_audit::ANCHOR_FILE).display() + ), + format!( + "\x1b[33manchors undetectable\x1b[0m {}", + anchors.join(airlock_audit::ANCHOR_FILE).display() + ) + ) ); println!(" {}", AnchorFailure::FileAbsent); - println!(" 세션 통째 삭제와 체인 재계산을 이 검증으로는 알 수 없음. 통과가 아님"); + println!( + "{}", + tr!( + " 세션 통째 삭제와 체인 재계산을 이 검증으로는 알 수 없음. 통과가 아님", + " whole-session deletion and chain recomputation cannot be detected \ + by this verification; not a pass" + ) + ); 0 } Err(failure) => { - println!("\x1b[1;31m앵커 실패\x1b[0m {}", anchors.display()); + println!( + "{}", + tr!( + format!("\x1b[1;31m앵커 실패\x1b[0m {}", anchors.display()), + format!("\x1b[1;31manchor failure\x1b[0m {}", anchors.display()) + ) + ); println!(" {failure}"); 2 } @@ -336,20 +545,37 @@ fn report_anchor_chain(anchors: &Path) -> i32 { fn report_session_anchor(anchors: &Path, report: &airlock_audit::VerifyReport) -> i32 { match check_session(anchors, &report.session, report.head_seq, &report.head_hash) { Ok(AnchorCheck::Matches { anchor_seq }) => { - println!(" 앵커 seq {anchor_seq}에서 head 일치"); + println!( + "{}", + tr!( + format!(" 앵커 seq {anchor_seq}에서 head 일치"), + format!(" anchors head matches at seq {anchor_seq}") + ) + ); 0 } // 앵커 줄이 없는 세션은 통과가 아니라 탐지 불가입니다. 이 세션이 통째로 // 지워졌어도 알아낼 방법이 없다는 뜻이므로 그대로 적습니다 Ok(AnchorCheck::Missing) => { println!( - " \x1b[33m앵커\x1b[0m 이 세션의 앵커 줄이 없음. 탐지 불가이며 통과가 아님" + "{}", + tr!( + " \x1b[33m앵커\x1b[0m 이 세션의 앵커 줄이 없음. 탐지 불가이며 통과가 아님", + " \x1b[33manchors\x1b[0m this session has no anchor line; \ + undetectable, which is not a pass" + ) ); 0 } Err(AnchorFailure::FileAbsent) => 0, Err(failure) => { - println!(" \x1b[1;31m앵커 불일치\x1b[0m {failure}"); + println!( + "{}", + tr!( + format!(" \x1b[1;31m앵커 불일치\x1b[0m {failure}"), + format!(" \x1b[1;31manchor mismatch\x1b[0m {failure}") + ) + ); 2 } } @@ -359,24 +585,55 @@ fn verify_one(dir: &Path, anchors: &Path) -> i32 { match verify_dir(dir) { Ok(report) => { println!( - "\x1b[32m무결성 확인\x1b[0m {} ({} 엔트리, head seq {})", - dir.display(), - report.entries, - report.head_seq + "{}", + tr!( + format!( + "\x1b[32m무결성 확인\x1b[0m {} ({} 엔트리, head seq {})", + dir.display(), + report.entries, + report.head_seq + ), + format!( + "\x1b[32mintegrity ok\x1b[0m {} ({} entries, head seq {})", + dir.display(), + report.entries, + report.head_seq + ) + ) + ); + println!( + "{}", + tr!( + format!(" 세션 {}", report.session), + format!(" session {}", report.session) + ) + ); + println!( + "{}", + tr!( + format!(" 체인헤드 {}", report.head_hash), + format!(" chain head {}", report.head_hash) + ) ); - println!(" 세션 {}", report.session); - println!(" 체인헤드 {}", report.head_hash); for w in &report.warnings { let label = match w { - Warning::ObserveOnlyEntries { .. } => "\x1b[33m강제없음\x1b[0m", - _ => "\x1b[33m경고\x1b[0m", + Warning::ObserveOnlyEntries { .. } => { + tr!("\x1b[33m강제없음\x1b[0m", "\x1b[33munenforced\x1b[0m") + } + _ => tr!("\x1b[33m경고\x1b[0m", "\x1b[33mwarning\x1b[0m"), }; println!(" {label} {w}"); } report_session_anchor(anchors, &report) } Err(failure) => { - println!("\x1b[1;31m무결성 실패\x1b[0m {}", dir.display()); + println!( + "{}", + tr!( + format!("\x1b[1;31m무결성 실패\x1b[0m {}", dir.display()), + format!("\x1b[1;31mintegrity failure\x1b[0m {}", dir.display()) + ) + ); println!(" {failure}"); 2 } @@ -402,11 +659,18 @@ fn decision_color(entry: &Entry) -> &'static str { /// `tty` - 승인 프롬프트가 나간 터미널 장치 경로 fn approver(uid: Option, tty: Option<&str>) -> String { match (uid, tty) { - (None, None) => "\x1b[33m승인자없음(사람 확인 아님)\x1b[0m".to_string(), + (None, None) => tr!( + "\x1b[33m승인자없음(사람 확인 아님)\x1b[0m", + "\x1b[33mno approver (not a human confirmation)\x1b[0m" + ) + .to_string(), (uid, tty) => { let uid = uid.map_or_else(|| "?".to_string(), |u| u.to_string()); let tty = tty.map_or_else(|| "?".to_string(), sanitize); - format!("승인자 uid={uid} tty={tty}") + tr!( + format!("승인자 uid={uid} tty={tty}"), + format!("approver uid={uid} tty={tty}") + ) } } } @@ -425,20 +689,29 @@ fn describe_event(event: &Event) -> String { let source = policy_source .as_deref() .map(sanitize) - .unwrap_or_else(|| "내장 베이스라인".to_string()); + .unwrap_or_else(|| tr!("내장 베이스라인", "built-in baseline").to_string()); let sync = if *fsync_per_entry { "" } else { - " [fsync 없음]" + tr!(" [fsync 없음]", " [no fsync]") }; // 중계 수준을 함께 보여 줍니다. exec 엔트리가 없는 체인이 "아무 일도 없었음" // 인지 "중계가 꺼져 있어 보이지 않았음"인지 구분되어야 합니다 - format!( - "세션 시작 정책={source} 다이제스트={short} 중계={}{sync} argv={argv:?}", - mediation.as_str() + tr!( + format!( + "세션 시작 정책={source} 다이제스트={short} 중계={}{sync} argv={argv:?}", + mediation.as_str() + ), + format!( + "session start policy={source} digest={short} mediation={}{sync} argv={argv:?}", + mediation.as_str() + ) ) } - Event::SessionEnd { status } => format!("세션 종료 {status:?}"), + Event::SessionEnd { status } => tr!( + format!("세션 종료 {status:?}"), + format!("session end {status:?}") + ), Event::FileAccess { path_requested, path_resolved, @@ -447,17 +720,29 @@ fn describe_event(event: &Event) -> String { let requested = sanitize(path_requested); let resolved = sanitize(path_resolved); if requested == resolved { - format!("파일 {mode} {requested}") + tr!( + format!("파일 {mode} {requested}"), + format!("file {mode} {requested}") + ) } else { - format!("파일 {mode} {requested} \x1b[35m-> {resolved}\x1b[0m") + tr!( + format!("파일 {mode} {requested} \x1b[35m-> {resolved}\x1b[0m"), + format!("file {mode} {requested} \x1b[35m-> {resolved}\x1b[0m") + ) } } - Event::Exec { program, argv, .. } => format!("실행 {} {argv:?}", sanitize(program)), + Event::Exec { program, argv, .. } => tr!( + format!("실행 {} {argv:?}", sanitize(program)), + format!("exec {} {argv:?}", sanitize(program)) + ), Event::Egress { host, port, protocol, - } => format!("아웃바운드 {protocol} {}:{port}", sanitize(host)), + } => tr!( + format!("아웃바운드 {protocol} {}:{port}", sanitize(host)), + format!("egress {protocol} {}:{port}", sanitize(host)) + ), Event::EgressSummary { host, port, @@ -465,12 +750,21 @@ fn describe_event(event: &Event) -> String { bytes_out, bytes_in, duration_ms, - } => format!( - "아웃바운드 종료 {protocol} {}:{port} 반출 {} 수신 {} {}", - sanitize(host), - render::human_bytes(*bytes_out), - render::human_bytes(*bytes_in), - render::human_millis(*duration_ms) + } => tr!( + format!( + "아웃바운드 종료 {protocol} {}:{port} 반출 {} 수신 {} {}", + sanitize(host), + render::human_bytes(*bytes_out), + render::human_bytes(*bytes_in), + render::human_millis(*duration_ms) + ), + format!( + "egress closed {protocol} {}:{port} sent {} received {} {}", + sanitize(host), + render::human_bytes(*bytes_out), + render::human_bytes(*bytes_in), + render::human_millis(*duration_ms) + ) ), Event::Approval { for_seq, @@ -479,7 +773,10 @@ fn describe_event(event: &Event) -> String { approver_uid, approver_tty, } => { - let mut s = format!("승인응답 seq={for_seq} {granted}"); + let mut s = tr!( + format!("승인응답 seq={for_seq} {granted}"), + format!("approval response seq={for_seq} {granted}") + ); s.push(' '); s.push_str(&approver(*approver_uid, approver_tty.as_deref())); if let Some(n) = note { @@ -489,7 +786,10 @@ fn describe_event(event: &Event) -> String { } Event::PolicyReload { policy_digest, .. } => { let short: String = policy_digest.to_hex().chars().take(12).collect(); - format!("정책 재적용 다이제스트={short}") + tr!( + format!("정책 재적용 다이제스트={short}"), + format!("policy reload digest={short}") + ) } } } @@ -509,20 +809,43 @@ fn show(dir: &Path, limit: usize, decisions_only: bool) -> i32 { if let Some(p) = &problem { // 경고와 엔트리를 같은 스트림에 둡니다. 리다이렉트나 페이저로 경고만 사라지면 // 조용히 뚫린 것과 같습니다 - println!("\x1b[1;31mairlock 경고\x1b[0m {p}"); + println!( + "{}", + tr!( + format!("\x1b[1;31mairlock 경고\x1b[0m {p}"), + format!("\x1b[1;31mairlock warning\x1b[0m {p}") + ) + ); } if let Err(failure) = &integrity { println!( - "\x1b[1;31m╔══ 무결성 실패 ═══════════════════════════════════\x1b[0m\n\ - \x1b[1;31m║\x1b[0m {failure}\n\ - \x1b[1;31m║\x1b[0m 아래 내용은 검증되지 않았으므로 증거로 쓸 수 없음\n\ - \x1b[1;31m╚═════════════════════════════════════════════════\x1b[0m" + "{}", + tr!( + format!( + "\x1b[1;31m╔══ 무결성 실패 ═══════════════════════════════════\x1b[0m\n\ + \x1b[1;31m║\x1b[0m {failure}\n\ + \x1b[1;31m║\x1b[0m 아래 내용은 검증되지 않았으므로 증거로 쓸 수 없음\n\ + \x1b[1;31m╚═════════════════════════════════════════════════\x1b[0m" + ), + format!( + "\x1b[1;31m╔══ integrity failure ═════════════════════════════\x1b[0m\n\ + \x1b[1;31m║\x1b[0m {failure}\n\ + \x1b[1;31m║\x1b[0m the content below is unverified and cannot be used as evidence\n\ + \x1b[1;31m╚═════════════════════════════════════════════════\x1b[0m" + ) + ) ); } if entries.is_empty() { - eprintln!("airlock: 읽을 수 있는 엔트리가 없음. airlock audit verify로 확인할 것"); + eprintln!( + "{}", + tr!( + "airlock: 읽을 수 있는 엔트리가 없음. airlock audit verify로 확인할 것", + "airlock: no readable entries; check with airlock audit verify" + ) + ); return 1; } @@ -547,7 +870,13 @@ fn show(dir: &Path, limit: usize, decisions_only: bool) -> i32 { }; if skipped > 0 { - println!("\x1b[2m앞선 {skipped}개 엔트리 생략. --limit로 조절\x1b[0m"); + println!( + "{}", + tr!( + format!("\x1b[2m앞선 {skipped}개 엔트리 생략. --limit로 조절\x1b[0m"), + format!("\x1b[2m{skipped} earlier entries omitted; adjust with --limit\x1b[0m") + ) + ); } for e in window { @@ -560,7 +889,7 @@ fn show(dir: &Path, limit: usize, decisions_only: bool) -> i32 { .rule .as_deref() .map(sanitize) - .unwrap_or_else(|| "기본값".to_string()); + .unwrap_or_else(|| tr!("기본값", "default").to_string()); println!( "{:>5} {time}Z {color}{:<6}\x1b[0m {:<9} {} \x1b[2m[{rule}]\x1b[0m", e.seq, @@ -576,7 +905,15 @@ fn show(dir: &Path, limit: usize, decisions_only: bool) -> i32 { .count(); if observed > 0 { println!( - "\n\x1b[33m주의\x1b[0m {observed}개 엔트리가 observe 모드임. 기록되었지만 강제되지 않음" + "{}", + tr!( + format!( + "\n\x1b[33m주의\x1b[0m {observed}개 엔트리가 observe 모드임. 기록되었지만 강제되지 않음" + ), + format!( + "\n\x1b[33mnote\x1b[0m {observed} entries are in observe mode; recorded but not enforced" + ) + ) ); } @@ -590,10 +927,22 @@ fn show(dir: &Path, limit: usize, decisions_only: bool) -> i32 { fn list(root: &Path) -> i32 { let sessions = paths::all_sessions(root); if sessions.is_empty() { - println!("{}에 세션이 없음", root.display()); + println!( + "{}", + tr!( + format!("{}에 세션이 없음", root.display()), + format!("no sessions under {}", root.display()) + ) + ); return 0; } - println!("{} 아래 {}개 세션", root.display(), sessions.len()); + println!( + "{}", + tr!( + format!("{} 아래 {}개 세션", root.display(), sessions.len()), + format!("{} sessions under {}", sessions.len(), root.display()) + ) + ); for s in sessions.iter().rev() { let name = s.file_name().unwrap_or_default().to_string_lossy(); let status = match verify_dir(s) { @@ -603,12 +952,18 @@ fn list(root: &Path) -> i32 { .iter() .any(|w| matches!(w, Warning::ObserveOnlyEntries { .. })); if unenforced { - format!("\x1b[33mobserve\x1b[0m {} 엔트리", r.entries) + tr!( + format!("\x1b[33mobserve\x1b[0m {} 엔트리", r.entries), + format!("\x1b[33mobserve\x1b[0m {} entries", r.entries) + ) } else { - format!("\x1b[32m정상\x1b[0m {} 엔트리", r.entries) + tr!( + format!("\x1b[32m정상\x1b[0m {} 엔트리", r.entries), + format!("\x1b[32mok\x1b[0m {} entries", r.entries) + ) } } - Err(_) => "\x1b[31m손상\x1b[0m".to_string(), + Err(_) => tr!("\x1b[31m손상\x1b[0m", "\x1b[31mdamaged\x1b[0m").to_string(), }; println!(" {name} {status}"); } diff --git a/crates/airlock/src/cmd_policy.rs b/crates/airlock/src/cmd_policy.rs index 0f29cf2..5d09120 100644 --- a/crates/airlock/src/cmd_policy.rs +++ b/crates/airlock/src/cmd_policy.rs @@ -1,38 +1,60 @@ use std::path::PathBuf; use airlock_canonical::display::sanitize; +use airlock_i18n::tr; use airlock_policy::{Action, FileMode, LoadContext, PLAINTEXT_FLOOR_ID, Policy, Protocol}; use crate::paths; #[derive(Debug, clap::Subcommand)] pub enum PolicyCommand { - #[command(about = "특정 요청에 대한 결정과 그 근거를 보여줌")] + #[command( + about = tr!( + "특정 요청에 대한 결정과 그 근거를 보여줌", + "show the decision for a specific request and its rationale" + ) + )] Explain { - #[arg(long, value_name = "PATH", help = "파일 경로에 대한 결정")] + #[arg( + long, + value_name = "PATH", + help = tr!("파일 경로에 대한 결정", "decision for a file path") + )] file: Option, #[arg( long, default_value = "read", - help = "파일 모드 read|write|create|delete|metadata|exec" + help = tr!( + "파일 모드 read|write|create|delete|metadata|exec", + "file mode read|write|create|delete|metadata|exec" + ) )] mode: String, - #[arg(long, value_name = "PROGRAM", help = "실행 결정")] + #[arg( + long, + value_name = "PROGRAM", + help = tr!("실행 결정", "decision for an execution") + )] exec: Option, - #[arg(long, value_name = "HOST", help = "아웃바운드 호스트")] + #[arg(long, value_name = "HOST", help = tr!("아웃바운드 호스트", "egress host"))] host: Option, - #[arg(long, default_value_t = 443, help = "아웃바운드 포트")] + #[arg(long, default_value_t = 443, help = tr!("아웃바운드 포트", "egress port"))] port: u16, #[arg( long, default_value = "tcp", - help = "아웃바운드 프로토콜 tcp|tls|http. 기본값 tcp는 관측 층이 프로토콜을 \ - 모른다는 뜻이며 중계 층이 넘기는 값과 같음" + help = tr!( + "아웃바운드 프로토콜 tcp|tls|http. 기본값 tcp는 관측 층이 프로토콜을 \ + 모른다는 뜻이며 중계 층이 넘기는 값과 같음", + "egress protocol tcp|tls|http; the default tcp means the observation \ + layer does not know the protocol, the same value the mediation \ + layer passes" + ) )] protocol: String, @@ -47,13 +69,18 @@ pub enum PolicyCommand { args: Vec, }, - #[command(about = "정책을 로드하고 문제를 보고함")] + #[command(about = tr!("정책을 로드하고 문제를 보고함", "load a policy and report problems"))] Check { #[arg(long, value_name = "FILE")] policy: Option, }, - #[command(about = "생성되는 OS 강제 프로파일을 그대로 출력함")] + #[command( + about = tr!( + "생성되는 OS 강제 프로파일을 그대로 출력함", + "print the generated OS enforcement profile as-is" + ) + )] Profile { #[arg(long, value_name = "FILE")] policy: Option, @@ -93,7 +120,13 @@ fn load( 78 })?, None => Policy::baseline_only(&ctx).map_err(|e| { - eprintln!("airlock: 내장 베이스라인 로드 실패: {e}"); + eprintln!( + "{}", + tr!( + format!("airlock: 내장 베이스라인 로드 실패: {e}"), + format!("airlock: failed to load the built-in baseline: {e}") + ) + ); 70 })?, }; @@ -120,7 +153,13 @@ pub fn exec(cmd: PolicyCommand, audit_root: Option) -> i32 { if let Some(path) = file { let Some(mode) = FileMode::parse(&mode) else { - eprintln!("airlock: 알 수 없는 mode `{mode}`"); + eprintln!( + "{}", + tr!( + format!("airlock: 알 수 없는 mode `{mode}`"), + format!("airlock: unknown mode `{mode}`") + ) + ); return 64; }; let ev = policy.evaluate_file(&path, mode, &cwd); @@ -145,7 +184,15 @@ pub fn exec(cmd: PolicyCommand, audit_root: Option) -> i32 { if let Some(host) = host { let Some(protocol) = Protocol::parse(&protocol) else { eprintln!( - "airlock: 알 수 없는 protocol `{protocol}`. tcp, tls, http 중 하나여야 함" + "{}", + tr!( + format!( + "airlock: 알 수 없는 protocol `{protocol}`. tcp, tls, http 중 하나여야 함" + ), + format!( + "airlock: unknown protocol `{protocol}`; must be one of tcp, tls, http" + ) + ) ); return 64; }; @@ -154,7 +201,13 @@ pub fn exec(cmd: PolicyCommand, audit_root: Option) -> i32 { return exit_for(ev.action); } - eprintln!("airlock: --file, --exec, --host 중 하나가 필요함"); + eprintln!( + "{}", + tr!( + "airlock: --file, --exec, --host 중 하나가 필요함", + "airlock: one of --file, --exec, --host is required" + ) + ); 64 } @@ -164,31 +217,80 @@ pub fn exec(cmd: PolicyCommand, audit_root: Option) -> i32 { Err(code) => return code, }; let digest = airlock_audit::Hash::from_bytes(policy.digest()); - println!("\x1b[32m정책 로드 성공\x1b[0m"); println!( - " 출처 {}", - path.map(|p| p.display().to_string()) - .unwrap_or_else(|| "내장 베이스라인".to_string()) + "{}", + tr!( + "\x1b[32m정책 로드 성공\x1b[0m", + "\x1b[32mpolicy loaded\x1b[0m" + ) + ); + let source = path + .map(|p| p.display().to_string()) + .unwrap_or_else(|| tr!("내장 베이스라인", "built-in baseline").to_string()); + println!( + "{}", + tr!( + format!(" 출처 {source}"), + format!(" source {source}") + ) + ); + println!( + "{}", + tr!( + format!(" 이름 {}", policy.name()), + format!(" name {}", policy.name()) + ) + ); + println!( + "{}", + tr!( + format!(" 다이제스트 {digest}"), + format!(" digest {digest}") + ) ); - println!(" 이름 {}", policy.name()); - println!(" 다이제스트 {digest}"); println!( - " 규칙 tier0 {} / 사용자 {} / 베이스라인 {}", - policy.self_protect_rules().len(), - policy.user_rules().len(), - policy.baseline_rules().len() + "{}", + tr!( + format!( + " 규칙 tier0 {} / 사용자 {} / 베이스라인 {}", + policy.self_protect_rules().len(), + policy.user_rules().len(), + policy.baseline_rules().len() + ), + format!( + " rules tier0 {} / user {} / baseline {}", + policy.self_protect_rules().len(), + policy.user_rules().len(), + policy.baseline_rules().len() + ) + ) ); let d = policy.defaults(); println!( - " 기본값 file={} exec={} egress={} egress_plaintext={}", - d.file, d.exec, d.egress, d.egress_plaintext + "{}", + tr!( + format!( + " 기본값 file={} exec={} egress={} egress_plaintext={}", + d.file, d.exec, d.egress, d.egress_plaintext + ), + format!( + " defaults file={} exec={} egress={} egress_plaintext={}", + d.file, d.exec, d.egress, d.egress_plaintext + ) + ) ); if policy.warnings().is_empty() { - println!(" 경고 없음"); + println!("{}", tr!(" 경고 없음", " warnings none")); 0 } else { - println!(" 경고 {}건", policy.warnings().len()); + println!( + "{}", + tr!( + format!(" 경고 {}건", policy.warnings().len()), + format!(" warnings {}", policy.warnings().len()) + ) + ); for w in policy.warnings() { println!(" \x1b[33m-\x1b[0m {w}"); } @@ -213,8 +315,17 @@ pub fn exec(cmd: PolicyCommand, audit_root: Option) -> i32 { print!("{}", generated.text); for item in &generated.untranslatable { eprintln!( - "airlock: 경고 프로파일로 옮기지 못한 규칙 {}", - sanitize(item) + "{}", + tr!( + format!( + "airlock: 경고 프로파일로 옮기지 못한 규칙 {}", + sanitize(item) + ), + format!( + "airlock: warning: rule not carried into the profile {}", + sanitize(item) + ) + ) ); } 0 @@ -242,26 +353,77 @@ fn colored(action: Action) -> String { fn print_rule(ev: &airlock_policy::Evaluation) { match &ev.rule { Some(rule) => { - println!("규칙 {} ({} tier)", sanitize(&rule.id), rule.tier); - println!("매칭 {}", sanitize(&rule.pattern)); + println!( + "{}", + tr!( + format!("규칙 {} ({} tier)", sanitize(&rule.id), rule.tier), + format!("rule {} ({} tier)", sanitize(&rule.id), rule.tier) + ) + ); + println!( + "{}", + tr!( + format!("매칭 {}", sanitize(&rule.pattern)), + format!("match {}", sanitize(&rule.pattern)) + ) + ); if let Some(reason) = &rule.reason { - println!("근거 {}", sanitize(reason)); + println!( + "{}", + tr!( + format!("근거 {}", sanitize(reason)), + format!("reason {}", sanitize(reason)) + ) + ); } } - None => println!("규칙 없음 (기본값 적용)"), + None => println!( + "{}", + tr!( + "규칙 없음 (기본값 적용)", + "rule none (default applied)" + ) + ), } } fn print_file(ev: &airlock_policy::Evaluation, mode: FileMode) { if let Some(np) = &ev.path { - println!("요청 {}", sanitize(&np.requested.display().to_string())); - println!("해소 {}", sanitize(&np.resolved.display().to_string())); + println!( + "{}", + tr!( + format!("요청 {}", sanitize(&np.requested.display().to_string())), + format!("request {}", sanitize(&np.requested.display().to_string())) + ) + ); + println!( + "{}", + tr!( + format!("해소 {}", sanitize(&np.resolved.display().to_string())), + format!("resolved {}", sanitize(&np.resolved.display().to_string())) + ) + ); if np.diverges() { - println!(" \x1b[35m경로가 다름. 더 제한적인 쪽이 채택됨\x1b[0m"); + println!( + "{}", + tr!( + " \x1b[35m경로가 다름. 더 제한적인 쪽이 채택됨\x1b[0m", + " \x1b[35mpaths differ; the more restrictive one is adopted\x1b[0m" + ) + ); } } - println!("모드 {mode}"); - println!("결정 {}", colored(ev.action)); + println!( + "{}", + tr!(format!("모드 {mode}"), format!("mode {mode}")) + ); + println!( + "{}", + tr!( + format!("결정 {}", colored(ev.action)), + format!("decision {}", colored(ev.action)) + ) + ); print_rule(ev); } @@ -273,49 +435,113 @@ fn print_file(ev: &airlock_policy::Evaluation, mode: FileMode) { /// `whitelist` - 이 정책에서 exec 이 커널 화이트리스트로 걸리는지 fn print_exec(ev: &airlock_policy::Evaluation, argv: &[String], whitelist: bool) { if let Some(np) = &ev.path { - println!("프로그램 {}", sanitize(&np.requested.display().to_string())); + println!( + "{}", + tr!( + format!("프로그램 {}", sanitize(&np.requested.display().to_string())), + format!("program {}", sanitize(&np.requested.display().to_string())) + ) + ); if np.diverges() { - println!("해소 {}", sanitize(&np.resolved.display().to_string())); + println!( + "{}", + tr!( + format!("해소 {}", sanitize(&np.resolved.display().to_string())), + format!("resolved {}", sanitize(&np.resolved.display().to_string())) + ) + ); } } println!("argv {argv:?}"); - println!("결정 {}", colored(ev.action)); + println!( + "{}", + tr!( + format!("결정 {}", colored(ev.action)), + format!("decision {}", colored(ev.action)) + ) + ); print_rule(ev); // 프로그램 경로와 argv 조건은 성질이 다릅니다. 둘을 한 문장으로 뭉뚱그리면 강제되는 // 것을 강제되지 않는다고 말하거나 그 반대가 됩니다 (docs/policy-dsl.md 7.1) if whitelist { println!( - "\x1b[2m참고 [defaults].exec 이 allow 가 아니므로 프로그램 경로는 커널 화이트리스트임. \ - argv 조건은 커널이 볼 수 없어 tripwire 로만 남음\x1b[0m" + "{}", + tr!( + "\x1b[2m참고 [defaults].exec 이 allow 가 아니므로 프로그램 경로는 커널 화이트리스트임. \ + argv 조건은 커널이 볼 수 없어 tripwire 로만 남음\x1b[0m", + "\x1b[2mnote: [defaults].exec is not allow, so the program path is a kernel \ + whitelist. argv conditions cannot be seen by the kernel and remain \ + tripwires only\x1b[0m" + ) ); } else if ev.action.is_restrictive() { println!( - "\x1b[2m참고 [defaults].exec = allow 라 exec 화이트리스트를 걸지 않음. \ - 이 규칙은 보안 경계가 아니라 tripwire 이며 실제 방어는 file 과 egress 규칙에서 나옴\x1b[0m" + "{}", + tr!( + "\x1b[2m참고 [defaults].exec = allow 라 exec 화이트리스트를 걸지 않음. \ + 이 규칙은 보안 경계가 아니라 tripwire 이며 실제 방어는 file 과 egress 규칙에서 나옴\x1b[0m", + "\x1b[2mnote: [defaults].exec = allow, so no exec whitelist is applied. this \ + rule is a tripwire, not a security boundary; the real defense comes from \ + the file and egress rules\x1b[0m" + ) ); } } fn print_egress(ev: &airlock_policy::Evaluation, host: &str, port: u16, protocol: Protocol) { - println!("호스트 {}", sanitize(host)); - println!("포트 {port}"); - println!("프로토콜 {protocol}"); - println!("결정 {}", colored(ev.action)); + println!( + "{}", + tr!( + format!("호스트 {}", sanitize(host)), + format!("host {}", sanitize(host)) + ) + ); + println!( + "{}", + tr!(format!("포트 {port}"), format!("port {port}")) + ); + println!( + "{}", + tr!( + format!("프로토콜 {protocol}"), + format!("protocol {protocol}") + ) + ); + println!( + "{}", + tr!( + format!("결정 {}", colored(ev.action)), + format!("decision {}", colored(ev.action)) + ) + ); print_rule(ev); // 평문 바닥이 결정을 바꿨을 때만 엔진이 합성 규칙을 냅니다. 그 사실을 드러내지 않으면 // 사용자는 자기가 적은 allow 규칙이 왜 통하지 않는지 알 수 없습니다 if ev.rule.as_ref().is_some_and(|r| r.id == PLAINTEXT_FLOOR_ID) { println!( - "\x1b[33m평문 바닥이 결정을 바꿨음.\x1b[0m [defaults].egress_plaintext 가 상한이며 \ - 열려면 그 egress 규칙에 protocol = \"http\" 를 명시할 것" + "{}", + tr!( + "\x1b[33m평문 바닥이 결정을 바꿨음.\x1b[0m [defaults].egress_plaintext 가 상한이며 \ + 열려면 그 egress 규칙에 protocol = \"http\" 를 명시할 것", + "\x1b[33mthe plaintext floor changed the decision.\x1b[0m \ + [defaults].egress_plaintext is the ceiling; to open it, state \ + protocol = \"http\" on that egress rule" + ) ); } if protocol == Protocol::Tcp { println!( - "\x1b[2m참고 protocol=tcp 는 관측 층이 프로토콜을 모른다는 뜻임. \ + "{}", + tr!( + "\x1b[2m참고 protocol=tcp 는 관측 층이 프로토콜을 모른다는 뜻임. \ 중계 층만으로 도는 세션은 항상 이 값이라 평문 바닥이 발동하지 않음. \ - 평문 판정은 airlock run --egress-proxy 위에서만 성립함\x1b[0m" + 평문 판정은 airlock run --egress-proxy 위에서만 성립함\x1b[0m", + "\x1b[2mnote: protocol=tcp means the observation layer does not know the \ + protocol. sessions running on the mediation layer alone always carry this \ + value, so the plaintext floor never fires. plaintext judgment only holds \ + on top of airlock run --egress-proxy\x1b[0m" + ) ); } } diff --git a/crates/airlock/src/cmd_run.rs b/crates/airlock/src/cmd_run.rs index 8f670b4..f48f63b 100644 --- a/crates/airlock/src/cmd_run.rs +++ b/crates/airlock/src/cmd_run.rs @@ -4,53 +4,89 @@ use airlock_broker::{ ApproveAll, Approver, Enforcer, ObserveEnforcer, ProfileOptions, RefuseAll, SessionConfig, TtyApprover, }; +use airlock_i18n::tr; use airlock_policy::{LoadContext, LoadWarning, Policy}; use crate::paths; #[derive(Debug, clap::Args)] pub struct RunArgs { - #[arg(long, value_name = "FILE", help = "정책 파일 경로")] + #[arg(long, value_name = "FILE", help = tr!("정책 파일 경로", "policy file path"))] pub policy: Option, - #[arg(long, help = "강제 없이 기록만 함 (학습 모드)")] + #[arg( + long, + help = tr!( + "강제 없이 기록만 함 (학습 모드)", + "record only, without enforcement (learning mode)" + ) + )] pub observe: bool, - #[arg(long, value_name = "DIR", help = "감사 로그 루트")] + #[arg(long, value_name = "DIR", help = tr!("감사 로그 루트", "audit log root"))] pub audit_dir: Option, #[arg( long, value_name = "DIR", - help = "세션 상위 앵커(anchors.jsonl)를 둘 디렉토리. 생략하면 감사 루트. \ + help = tr!( + "세션 상위 앵커(anchors.jsonl)를 둘 디렉토리. 생략하면 감사 루트. \ 같은 트리에 두면 체인을 재계산할 수 있는 주체가 앵커도 같은 비용으로 \ 재계산하므로, 실질 탐지력은 다른 볼륨이나 원격 append-only 마운트로 \ - 분리했을 때만 생김" + 분리했을 때만 생김", + "directory for the session anchors (anchors.jsonl); defaults to the audit \ + root. If it lives in the same tree, whoever can recompute the chain can \ + recompute the anchors at the same cost, so real detection power only \ + comes from a separate volume or a remote append-only mount" + ) )] pub anchor_dir: Option, - #[arg(long, value_name = "DIR", help = "쓰기 허용 작업 공간. 기본값은 cwd")] + #[arg( + long, + value_name = "DIR", + help = tr!( + "쓰기 허용 작업 공간. 기본값은 cwd", + "writable workspace; defaults to the cwd" + ) + )] pub workspace: Option, - #[arg(long, help = "아웃바운드 네트워크를 통째로 차단함")] + #[arg( + long, + help = tr!( + "아웃바운드 네트워크를 통째로 차단함", + "block outbound networking entirely" + ) + )] pub no_network: bool, #[arg( long, - help = "아웃바운드를 로컬 egress 프록시로 강제 경유시켜 호스트 단위 정책을 강제함. \ - 프록시 설정을 무시하는 도구는 연결에 실패함" + help = tr!( + "아웃바운드를 로컬 egress 프록시로 강제 경유시켜 호스트 단위 정책을 강제함. \ + 프록시 설정을 무시하는 도구는 연결에 실패함", + "force outbound traffic through a local egress proxy so per-host policy is \ + enforced; tools that ignore proxy settings will fail to connect" + ) )] pub egress_proxy: bool, #[arg( long, - help = "모든 ask를 사람 확인 없이 승인함. 승인 통제를 포기하는 설정임" + help = tr!( + "모든 ask를 사람 확인 없이 승인함. 승인 통제를 포기하는 설정임", + "approve every ask without human confirmation; this abandons approval control" + ) )] pub yes: bool, #[arg( long, - help = "엔트리마다 fsync 하지 않음. 크래시 시 구간 손실을 감수함" + help = tr!( + "엔트리마다 fsync 하지 않음. 크래시 시 구간 손실을 감수함", + "do not fsync per entry; accepts losing a window of entries on a crash" + ) )] pub no_fsync: bool, @@ -58,7 +94,11 @@ pub struct RunArgs { long, default_value = "exec-net", value_name = "LEVEL", - help = "런타임 중계 수준 off|exec-net|full. full은 파일 열기까지 기록하지만 느림 (Linux 전용)" + help = tr!( + "런타임 중계 수준 off|exec-net|full. full은 파일 열기까지 기록하지만 느림 (Linux 전용)", + "runtime mediation level off|exec-net|full; full also records file opens but \ + is slow (Linux only)" + ) )] pub mediate: String, @@ -67,7 +107,7 @@ pub struct RunArgs { allow_hyphen_values = true, required = true, value_name = "CMD", - help = "실행할 명령과 인자" + help = tr!("실행할 명령과 인자", "command and arguments to run") )] pub command: Vec, } @@ -76,15 +116,30 @@ pub fn exec(args: RunArgs, global_audit_root: Option) -> i32 { let cwd = match std::env::current_dir() { Ok(d) => d, Err(e) => { - eprintln!("airlock: 현재 디렉토리를 알 수 없음: {e}"); + eprintln!( + "airlock: {}", + tr!( + format!("현재 디렉토리를 알 수 없음: {e}"), + format!("cannot determine the current directory: {e}") + ) + ); return 70; } }; let Some(mediation) = airlock_broker::Mediation::parse(&args.mediate) else { eprintln!( - "airlock: 알 수 없는 --mediate `{}`. off, exec-net, full 중 하나여야 함", - args.mediate + "airlock: {}", + tr!( + format!( + "알 수 없는 --mediate `{}`. off, exec-net, full 중 하나여야 함", + args.mediate + ), + format!( + "unknown --mediate `{}`; must be one of off, exec-net, full", + args.mediate + ) + ) ); return 64; }; @@ -118,8 +173,20 @@ pub fn exec(args: RunArgs, global_audit_root: Option) -> i32 { // HOME이 없거나 상대 경로면 ~/ 앵커 forbid가 전부 엉뚱한 곳을 가리킵니다. 시크릿 // 보호가 사라진 채로 도는 것보다 중단이 낫습니다 let Some(home) = airlock_policy::path::home_dir_checked() else { - eprintln!("airlock: HOME이 비어 있거나 절대 경로가 아님"); - eprintln!("airlock: ~/ 로 시작하는 시크릿 보호 규칙이 전부 무효가 되므로 실행을 중단함"); + eprintln!( + "airlock: {}", + tr!( + "HOME이 비어 있거나 절대 경로가 아님", + "HOME is empty or not an absolute path" + ) + ); + eprintln!( + "airlock: {}", + tr!( + "~/ 로 시작하는 시크릿 보호 규칙이 전부 무효가 되므로 실행을 중단함", + "aborting because every ~/-anchored secret protection rule would be void" + ) + ); return 78; }; @@ -133,14 +200,26 @@ pub fn exec(args: RunArgs, global_audit_root: Option) -> i32 { Ok(policy) => policy, Err(e) => { eprintln!("airlock: {e}"); - eprintln!("airlock: 정책이 적용되지 않았으므로 실행을 중단함"); + eprintln!( + "airlock: {}", + tr!( + "정책이 적용되지 않았으므로 실행을 중단함", + "aborting because the policy was not applied" + ) + ); return 78; } }, None => match Policy::baseline_only(&ctx) { Ok(policy) => policy, Err(e) => { - eprintln!("airlock: 내장 베이스라인 로드 실패: {e}"); + eprintln!( + "airlock: {}", + tr!( + format!("내장 베이스라인 로드 실패: {e}"), + format!("failed to load the built-in baseline: {e}") + ) + ); return 70; } }, @@ -160,7 +239,7 @@ pub fn exec(args: RunArgs, global_audit_root: Option) -> i32 { { continue; } - eprintln!("airlock: 경고 {w}"); + eprintln!("airlock: {} {w}", tr!("경고", "warning")); } let workspace = paths::absolutize(&args.workspace.clone().unwrap_or_else(|| cwd.clone()), &cwd); @@ -175,16 +254,33 @@ pub fn exec(args: RunArgs, global_audit_root: Option) -> i32 { match airlock_proxy::ProxyServer::bind() { Ok(server) => Some(server), Err(e) => { - eprintln!("airlock: egress 프록시를 띄우지 못함: {e}"); eprintln!( - "airlock: 프록시 없이 계속하면 호스트 정책이 강제되지 않으므로 실행을 중단함" + "airlock: {}", + tr!( + format!("egress 프록시를 띄우지 못함: {e}"), + format!("failed to start the egress proxy: {e}") + ) + ); + eprintln!( + "airlock: {}", + tr!( + "프록시 없이 계속하면 호스트 정책이 강제되지 않으므로 실행을 중단함", + "aborting because continuing without the proxy would leave host \ + policy unenforced" + ) ); return 70; } } } else { if args.egress_proxy && args.no_network { - eprintln!("airlock: --no-network가 있으므로 --egress-proxy는 무시됨"); + eprintln!( + "airlock: {}", + tr!( + "--no-network가 있으므로 --egress-proxy는 무시됨", + "--egress-proxy is ignored because --no-network is set" + ) + ); } None }; @@ -201,8 +297,13 @@ pub fn exec(args: RunArgs, global_audit_root: Option) -> i32 { Err(why) => { eprintln!("airlock: {why}"); eprintln!( - "airlock: 커널 강제 없이는 에이전트를 격리할 수 없으므로 실행을 중단함. \ - 기록만 원하면 --observe를 명시할 것" + "airlock: {}", + tr!( + "커널 강제 없이는 에이전트를 격리할 수 없으므로 실행을 중단함. \ + 기록만 원하면 --observe를 명시할 것", + "aborting because the agent cannot be isolated without kernel \ + enforcement; pass --observe explicitly if you only want recording" + ) ); return 70; } @@ -211,15 +312,26 @@ pub fn exec(args: RunArgs, global_audit_root: Option) -> i32 { let approver: Box = if args.yes { eprintln!( - "airlock: 경고 --yes는 모든 ask를 사람 확인 없이 승인함. 감사 로그에 자동 승인으로 기록됨" + "airlock: {}", + tr!( + "경고 --yes는 모든 ask를 사람 확인 없이 승인함. 감사 로그에 자동 승인으로 기록됨", + "warning: --yes approves every ask without human confirmation; recorded \ + in the audit log as automatic approval" + ) ); Box::new(ApproveAll) } else if TtyApprover::available() { Box::new(TtyApprover::new()) } else { - eprintln!("airlock: 경고 /dev/tty가 없어 모든 ask를 거부함"); + eprintln!( + "airlock: {}", + tr!( + "경고 /dev/tty가 없어 모든 ask를 거부함", + "warning: refusing every ask because /dev/tty is unavailable" + ) + ); Box::new(RefuseAll { - why: "제어 터미널 없음".to_string(), + why: tr!("제어 터미널 없음", "no controlling terminal").to_string(), }) }; @@ -346,26 +458,51 @@ fn check_workspace(workspace: &std::path::Path, explicit: bool) -> Result<(), St let ws = canon(workspace); if ws.parent().is_none() { - return Err(format!( - "작업 공간이 파일시스템 루트({})임. 루트를 쓰기 허용으로 열지 않음. \ - --workspace 로 실제 작업 디렉토리를 지정할 것", - ws.display() + return Err(tr!( + format!( + "작업 공간이 파일시스템 루트({})임. 루트를 쓰기 허용으로 열지 않음. \ + --workspace 로 실제 작업 디렉토리를 지정할 것", + ws.display() + ), + format!( + "the workspace is the filesystem root ({}); refusing to open the root \ + for writing. Point --workspace at the actual working directory", + ws.display() + ) )); } let home = canon(&airlock_policy::path::home_dir()); if ws == home { if !explicit { - return Err(format!( - "작업 공간이 홈 전체({})가 됨. 작업 공간은 통째로 쓰기 허용이므로 \ - 홈에서 그냥 실행하지 않음. 하위 디렉토리로 옮기거나 --workspace 로 \ - 좁혀서 지정할 것", - ws.display() + return Err(tr!( + format!( + "작업 공간이 홈 전체({})가 됨. 작업 공간은 통째로 쓰기 허용이므로 \ + 홈에서 그냥 실행하지 않음. 하위 디렉토리로 옮기거나 --workspace 로 \ + 좁혀서 지정할 것", + ws.display() + ), + format!( + "the workspace would be your entire home ({}); the workspace is \ + writable as a whole, so refusing to run bare from home. Move into a \ + subdirectory or narrow it with --workspace", + ws.display() + ) )); } eprintln!( - "airlock: 경고 작업 공간이 홈 전체({})임. 홈 아래 모든 파일이 쓰기 허용됨", - ws.display() + "airlock: {}", + tr!( + format!( + "경고 작업 공간이 홈 전체({})임. 홈 아래 모든 파일이 쓰기 허용됨", + ws.display() + ), + format!( + "warning: the workspace is your entire home ({}); every file under \ + home becomes writable", + ws.display() + ) + ) ); } Ok(()) @@ -416,7 +553,11 @@ fn build_enforcer( { if !airlock_broker::LandlockEnforcer::available() { let _ = (workspace, allow_network, proxy); - return Err("커널이 Landlock을 지원하지 않음(5.13 이상 필요)".to_string()); + return Err(tr!( + "커널이 Landlock을 지원하지 않음(5.13 이상 필요)", + "the kernel does not support Landlock (5.13 or later required)" + ) + .to_string()); } Ok(Box::new( airlock_broker::LandlockEnforcer::new().with_options(options( @@ -429,7 +570,11 @@ fn build_enforcer( #[cfg(not(any(target_os = "macos", target_os = "linux")))] { let _ = (workspace, allow_network, proxy); - Err("이 플랫폼의 커널 강제 백엔드가 아직 없음".to_string()) + Err(tr!( + "이 플랫폼의 커널 강제 백엔드가 아직 없음", + "no kernel enforcement backend exists for this platform yet" + ) + .to_string()) } } @@ -448,15 +593,27 @@ fn egress_observation_gaps(policy: &Policy, proxy: Option) return Vec::new(); } let mut gaps = vec![ - "중계 층은 connect(2)만 보므로 모든 연결을 protocol=tcp로 보고함. \ - 프록시 없이는 protocol 조건 규칙이 아무것도 매칭하지 않음" - .to_string(), + tr!( + "중계 층은 connect(2)만 보므로 모든 연결을 protocol=tcp로 보고함. \ + 프록시 없이는 protocol 조건 규칙이 아무것도 매칭하지 않음", + "the mediation layer only sees connect(2), so every connection is reported \ + as protocol=tcp; without the proxy, no protocol-conditioned rule matches \ + anything" + ) + .to_string(), ]; let floor = policy.defaults().egress_plaintext; if floor.blocks() { - gaps.push(format!( - "[defaults].egress_plaintext = \"{floor}\" 가 이 세션에서는 한 번도 발동하지 않음. \ - 평문 판정은 --egress-proxy 위에서만 성립하므로 지금은 평문 차단이 강제되지 않음" + gaps.push(tr!( + format!( + "[defaults].egress_plaintext = \"{floor}\" 가 이 세션에서는 한 번도 발동하지 않음. \ + 평문 판정은 --egress-proxy 위에서만 성립하므로 지금은 평문 차단이 강제되지 않음" + ), + format!( + "[defaults].egress_plaintext = \"{floor}\" never fires in this session; \ + plaintext detection only exists on top of --egress-proxy, so plaintext \ + blocking is not enforced right now" + ) )); } let protocol_rules: Vec<&str> = policy @@ -474,10 +631,17 @@ fn egress_observation_gaps(policy: &Policy, proxy: Option) .map(|r| r.id.as_str()) .collect(); if !protocol_rules.is_empty() { - gaps.push(format!( - "protocol을 지정한 egress 규칙 {}개가 이 세션에서 죽어 있음: {}", - protocol_rules.len(), - protocol_rules.join(", ") + gaps.push(tr!( + format!( + "protocol을 지정한 egress 규칙 {}개가 이 세션에서 죽어 있음: {}", + protocol_rules.len(), + protocol_rules.join(", ") + ), + format!( + "{} egress rule(s) with a protocol condition are dead in this session: {}", + protocol_rules.len(), + protocol_rules.join(", ") + ) )); } gaps @@ -499,38 +663,116 @@ fn print_banner( let effective = airlock_broker::effective_mediation(mediation); eprintln!("\x1b[1;36mairlock\x1b[0m {}", env!("CARGO_PKG_VERSION")); eprintln!( - " 정책 {} ({} 규칙, 다이제스트 {short})", - policy.name(), - policy.rule_count() + "{}", + tr!( + format!( + " 정책 {} ({} 규칙, 다이제스트 {short})", + policy.name(), + policy.rule_count() + ), + format!( + " policy {} ({} rules, digest {short})", + policy.name(), + policy.rule_count() + ) + ) + ); + eprintln!( + "{}", + tr!( + format!(" 강제 {}", enforcer.describe()), + format!(" enforce {}", enforcer.describe()) + ) ); - eprintln!(" 강제 {}", enforcer.describe()); if effective == mediation { - eprintln!(" 중계 {}", effective.as_str()); + eprintln!( + "{}", + tr!( + format!(" 중계 {}", effective.as_str()), + format!(" mediation {}", effective.as_str()) + ) + ); } else { // 요청값과 적용값이 다르면 둘을 같이 보여 줍니다. 요청값만 보여 주면 // 배너가 실제보다 강한 보증을 하는 것이 됩니다 eprintln!( - " 중계 {} (요청 {})", - effective.as_str(), - mediation.as_str() + "{}", + tr!( + format!( + " 중계 {} (요청 {})", + effective.as_str(), + mediation.as_str() + ), + format!( + " mediation {} (requested {})", + effective.as_str(), + mediation.as_str() + ) + ) ); } - eprintln!(" 작업공간 {}", workspace.display()); + eprintln!( + "{}", + tr!( + format!(" 작업공간 {}", workspace.display()), + format!(" workspace {}", workspace.display()) + ) + ); match proxy { - Some(addr) => eprintln!(" 아웃바운드 {addr} 경유. 호스트 정책이 강제됨"), - None => eprintln!(" 아웃바운드 프록시 없음. 호스트 규칙은 의도 선언에 그침"), + Some(addr) => eprintln!( + "{}", + tr!( + format!(" 아웃바운드 {addr} 경유. 호스트 정책이 강제됨"), + format!(" egress via {addr}; host policy is enforced") + ) + ), + None => eprintln!( + "{}", + tr!( + " 아웃바운드 프록시 없음. 호스트 규칙은 의도 선언에 그침", + " egress no proxy; host rules remain declarations of intent" + ) + ), } - eprintln!(" 승인 {}", approver.describe()); - eprintln!(" 감사 {}", session_dir.display()); + eprintln!( + "{}", + tr!( + format!(" 승인 {}", approver.describe()), + format!(" approval {}", approver.describe()) + ) + ); + eprintln!( + "{}", + tr!( + format!(" 감사 {}", session_dir.display()), + format!(" audit {}", session_dir.display()) + ) + ); let anchors = airlock_broker::anchor_dir_for(session_dir, anchor_dir); if anchor_dir.is_some() { - eprintln!(" 앵커 {}", anchors.display()); + eprintln!( + "{}", + tr!( + format!(" 앵커 {}", anchors.display()), + format!(" anchors {}", anchors.display()) + ) + ); } else { // 같은 트리에 두면 체인을 다시 계산할 수 있는 주체가 앵커도 같은 비용으로 다시 // 계산합니다. 분리하지 않았다는 사실을 배너가 감추면 없는 보증을 믿게 됩니다 eprintln!( - " 앵커 {} (감사 루트와 같은 트리. 재계산 탐지력 없음, --anchor-dir로 분리할 것)", - anchors.display() + "{}", + tr!( + format!( + " 앵커 {} (감사 루트와 같은 트리. 재계산 탐지력 없음, --anchor-dir로 분리할 것)", + anchors.display() + ), + format!( + " anchors {} (same tree as the audit root; no recomputation \ + detection, separate it with --anchor-dir)", + anchors.display() + ) + ) ); } for gap in enforcer @@ -539,7 +781,13 @@ fn print_banner( .chain(airlock_broker::mediation_gaps(mediation)) .chain(egress_observation_gaps(policy, proxy)) { - eprintln!(" \x1b[33m한계\x1b[0m {gap}"); + eprintln!( + "{}", + tr!( + format!(" \x1b[33m한계\x1b[0m {gap}"), + format!(" \x1b[33mlimit\x1b[0m {gap}") + ) + ); } eprintln!(); } @@ -547,32 +795,101 @@ fn print_banner( fn print_summary(report: &airlock_broker::RunReport) { let short: String = report.head_hash.to_hex().chars().take(12).collect(); eprintln!(); - eprintln!("\x1b[1;36mairlock\x1b[0m 세션 종료"); - eprintln!(" 강제 {}", report.enforcement); - eprintln!(" 중계 {}", report.mediation.as_str()); + eprintln!( + "\x1b[1;36mairlock\x1b[0m {}", + tr!("세션 종료", "session ended") + ); + eprintln!( + "{}", + tr!( + format!(" 강제 {}", report.enforcement), + format!(" enforce {}", report.enforcement) + ) + ); + eprintln!( + "{}", + tr!( + format!(" 중계 {}", report.mediation.as_str()), + format!(" mediation {}", report.mediation.as_str()) + ) + ); if let Some(signal) = report.signal { - eprintln!(" 종료 시그널 {signal}"); + eprintln!( + "{}", + tr!( + format!(" 종료 시그널 {signal}"), + format!(" exit signal {signal}") + ) + ); } - eprintln!(" 승인요청 {}", report.asked); - eprintln!(" 차단 {}", report.denied); - eprintln!(" 체인헤드 {short}"); + eprintln!( + "{}", + tr!( + format!(" 승인요청 {}", report.asked), + format!(" asked {}", report.asked) + ) + ); + eprintln!( + "{}", + tr!( + format!(" 차단 {}", report.denied), + format!(" denied {}", report.denied) + ) + ); + eprintln!( + "{}", + tr!( + format!(" 체인헤드 {short}"), + format!(" chain head {short}") + ) + ); match report.anchor.failure() { - None => eprintln!(" 앵커 {}", report.anchor.path().display()), + None => eprintln!( + "{}", + tr!( + format!(" 앵커 {}", report.anchor.path().display()), + format!(" anchors {}", report.anchor.path().display()) + ) + ), // 앵커 없는 세션은 감사 보증이 약해진 세션입니다. 자식은 이미 끝났으므로 종료 // 코드를 덮지는 않지만, 조용히 넘기면 사용자가 그 사실을 영영 모릅니다 Some(why) => { eprintln!( - " \x1b[1;31m앵커 실패\x1b[0m {} : {why}", - report.anchor.path().display() + "{}", + tr!( + format!( + " \x1b[1;31m앵커 실패\x1b[0m {} : {why}", + report.anchor.path().display() + ), + format!( + " \x1b[1;31manchor failure\x1b[0m {} : {why}", + report.anchor.path().display() + ) + ) ); eprintln!( - " \x1b[1;31m경고\x1b[0m 이 세션은 상위 앵커에 남지 않았음. \ - 세션 통째 삭제와 체인 재계산을 탐지할 수 없음" + "{}", + tr!( + " \x1b[1;31m경고\x1b[0m 이 세션은 상위 앵커에 남지 않았음. \ + 세션 통째 삭제와 체인 재계산을 탐지할 수 없음", + " \x1b[1;31mwarning\x1b[0m this session was not recorded in the \ + anchors; whole-session deletion and chain recomputation cannot be \ + detected" + ) ); } } eprintln!( - " 검증 airlock audit verify {}", - report.audit_dir.display() + "{}", + tr!( + format!( + " 검증 airlock audit verify {}", + report.audit_dir.display() + ), + format!( + " verify airlock audit verify {}", + report.audit_dir.display() + ) + ) ); } diff --git a/crates/airlock/src/cmd_setup.rs b/crates/airlock/src/cmd_setup.rs index 8c5593c..12b07a9 100644 --- a/crates/airlock/src/cmd_setup.rs +++ b/crates/airlock/src/cmd_setup.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use airlock_i18n::tr; use airlock_setup::{Outcome, SetupOptions}; #[derive(Debug, clap::Args)] @@ -7,7 +8,10 @@ pub struct SetupArgs { #[arg( long, value_name = "FILE", - help = "생성할 정책 파일 경로. 기본값은 ./airlock.toml" + help = tr!( + "생성할 정책 파일 경로. 기본값은 ./airlock.toml", + "path of the policy file to generate; defaults to ./airlock.toml" + ) )] pub out: Option, } @@ -16,7 +20,13 @@ pub fn exec(args: SetupArgs, global_audit_root: Option) -> i32 { let cwd = match std::env::current_dir() { Ok(c) => c, Err(e) => { - eprintln!("현재 디렉토리를 확인할 수 없음: {e}"); + eprintln!( + "{}", + tr!( + format!("현재 디렉토리를 확인할 수 없음: {e}"), + format!("cannot determine the current directory: {e}") + ) + ); return 2; } }; diff --git a/crates/airlock/src/main.rs b/crates/airlock/src/main.rs index 3e1c635..c0c825f 100644 --- a/crates/airlock/src/main.rs +++ b/crates/airlock/src/main.rs @@ -8,22 +8,33 @@ mod report; use std::path::PathBuf; +use airlock_i18n::tr; use clap::{Parser, Subcommand}; #[derive(Debug, Parser)] #[command( name = "airlock", version, - about = "AI 코딩 에이전트를 위한 로컬 제로트러스트 게이트웨이", - long_about = "에이전트의 파일 접근, 프로세스 실행, 아웃바운드 연결을 경계에서 중재하고 \ -변조 탐지 가능한 해시체인 감사 로그로 남김" + about = tr!( + "AI 코딩 에이전트를 위한 로컬 제로트러스트 게이트웨이", + "a local zero-trust gateway for AI coding agents" + ), + long_about = tr!( + "에이전트의 파일 접근, 프로세스 실행, 아웃바운드 연결을 경계에서 중재하고 \ +변조 탐지 가능한 해시체인 감사 로그로 남김", + "mediates the agent's file access, process execution, and outbound connections \ +at the boundary and records them in a tamper-evident hash-chained audit log" + ) )] struct Cli { #[arg( long, global = true, value_name = "DIR", - help = "감사 로그 루트로, AIRLOCK_AUDIT_DIR로도 지정할 수 있음" + help = tr!( + "감사 로그 루트로, AIRLOCK_AUDIT_DIR로도 지정할 수 있음", + "audit log root, can also be set via AIRLOCK_AUDIT_DIR" + ) )] audit_root: Option, @@ -33,20 +44,32 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { - #[command(about = "브로커 아래에서 명령 실행")] + #[command(about = tr!("브로커 아래에서 명령 실행", "run a command under the broker"))] Run(cmd_run::RunArgs), - #[command(subcommand, about = "감사 로그를 검증 및 조회")] + #[command( + subcommand, + about = tr!("감사 로그를 검증 및 조회", "verify and inspect the audit log") + )] Audit(cmd_audit::AuditCommand), - #[command(subcommand, about = "정책을 검사 및 설명")] + #[command( + subcommand, + about = tr!("정책을 검사 및 설명", "check and explain a policy") + )] Policy(cmd_policy::PolicyCommand), - #[command(about = "대화형으로 정책 파일 생성")] + #[command( + about = tr!( + "대화형으로 정책 파일 생성", + "generate a policy file interactively" + ) + )] Setup(cmd_setup::SetupArgs), } fn main() { + airlock_i18n::init(); let cli = Cli::parse(); let code = match cli.command { Command::Run(args) => cmd_run::exec(args, cli.audit_root), diff --git a/crates/airlock/src/render.rs b/crates/airlock/src/render.rs index 03711ec..11c141c 100644 --- a/crates/airlock/src/render.rs +++ b/crates/airlock/src/render.rs @@ -10,6 +10,7 @@ use airlock_audit::Verdict; use airlock_canonical::display::sanitize; +use airlock_i18n::tr; use serde_json::{Value, json}; use crate::report::{ @@ -68,9 +69,15 @@ pub fn short_time(ts: u64) -> String { fn range_label(report: &Report) -> String { match (&report.since, &report.until) { - (None, None) => "전체".to_string(), - (Some(a), None) => format!("{} 이후", sanitize(a)), - (None, Some(b)) => format!("{} 까지", sanitize(b)), + (None, None) => tr!("전체", "all").to_string(), + (Some(a), None) => tr!( + format!("{} 이후", sanitize(a)), + format!("since {}", sanitize(a)) + ), + (None, Some(b)) => tr!( + format!("{} 까지", sanitize(b)), + format!("until {}", sanitize(b)) + ), (Some(a), Some(b)) => format!("{} .. {}", sanitize(a), sanitize(b)), } } @@ -87,14 +94,56 @@ pub fn human(report: &Report) { .chars() .take(16) .collect(); - println!("\x1b[1;36mairlock\x1b[0m 감사 점검 보고"); - println!(" 감사루트 {}", report.root.display()); - println!(" 앵커루트 {}", report.anchor_dir.display()); - println!(" 범위 {}", range_label(report)); - println!(" 생성 {}", short_time(report.generated_ts)); - println!(" 다이제스트 {digest}"); + println!( + "{}", + tr!( + "\x1b[1;36mairlock\x1b[0m 감사 점검 보고", + "\x1b[1;36mairlock\x1b[0m audit check report" + ) + ); + println!( + "{}", + tr!( + format!(" 감사루트 {}", report.root.display()), + format!(" audit root {}", report.root.display()) + ) + ); + println!( + "{}", + tr!( + format!(" 앵커루트 {}", report.anchor_dir.display()), + format!(" anchor root {}", report.anchor_dir.display()) + ) + ); + println!( + "{}", + tr!( + format!(" 범위 {}", range_label(report)), + format!(" range {}", range_label(report)) + ) + ); + println!( + "{}", + tr!( + format!(" 생성 {}", short_time(report.generated_ts)), + format!(" generated {}", short_time(report.generated_ts)) + ) + ); + println!( + "{}", + tr!( + format!(" 다이제스트 {digest}"), + format!(" digest {digest}") + ) + ); if report.strict_approval { - println!(" 승인정책 --strict-approval (자동 승인을 이상으로 셈)"); + println!( + "{}", + tr!( + " 승인정책 --strict-approval (자동 승인을 이상으로 셈)", + " approval --strict-approval (counts automatic approvals as anomalies)" + ) + ); } println!(); @@ -103,9 +152,15 @@ pub fn human(report: &Report) { println!(); if report.sessions.is_empty() { - println!("범위 안에 세션이 없음"); + println!("{}", tr!("범위 안에 세션이 없음", "no sessions in range")); } else { - println!("세션 {}개", report.sessions.len()); + println!( + "{}", + tr!( + format!("세션 {}개", report.sessions.len()), + format!("{} sessions", report.sessions.len()) + ) + ); for s in &report.sessions { print_session(s); } @@ -126,18 +181,45 @@ fn print_chain(report: &Report) { warnings, } => { println!( - "\x1b[32m앵커 확인\x1b[0m {} 줄, 세션 {sessions}개, head seq {head_seq}", - entries + "{}", + tr!( + format!( + "\x1b[32m앵커 확인\x1b[0m {} 줄, 세션 {sessions}개, head seq {head_seq}", + entries + ), + format!( + "\x1b[32manchors ok\x1b[0m {} lines, {sessions} sessions, head seq {head_seq}", + entries + ) + ) ); for w in warnings { - println!(" \x1b[33m경고\x1b[0m {}", sanitize(w)); + println!( + "{}", + tr!( + format!(" \x1b[33m경고\x1b[0m {}", sanitize(w)), + format!(" \x1b[33mwarning\x1b[0m {}", sanitize(w)) + ) + ); } } ChainState::Absent => { - println!("\x1b[1;31m앵커 없음\x1b[0m 탐지 불가이며 통과가 아님"); + println!( + "{}", + tr!( + "\x1b[1;31m앵커 없음\x1b[0m 탐지 불가이며 통과가 아님", + "\x1b[1;31manchors absent\x1b[0m undetectable, which is not a pass" + ) + ); } ChainState::Broken { detail } => { - println!("\x1b[1;31m앵커 실패\x1b[0m {}", sanitize(detail)); + println!( + "{}", + tr!( + format!("\x1b[1;31m앵커 실패\x1b[0m {}", sanitize(detail)), + format!("\x1b[1;31manchor failure\x1b[0m {}", sanitize(detail)) + ) + ); } } } @@ -146,13 +228,29 @@ fn print_review(report: &Report) { match &report.review { ReviewState::None => { println!( - "\x1b[33m확인 기록 없음\x1b[0m {} 에 아직 아무도 도장을 찍지 않았음. \ + "{}", + tr!( + format!( + "\x1b[33m확인 기록 없음\x1b[0m {} 에 아직 아무도 도장을 찍지 않았음. \ airlock audit ack 로 남길 것", - report.anchor_dir.join(airlock_audit::REVIEW_FILE).display() + report.anchor_dir.join(airlock_audit::REVIEW_FILE).display() + ), + format!( + "\x1b[33mno review record\x1b[0m no one has stamped {} yet; \ + leave one with airlock audit ack", + report.anchor_dir.join(airlock_audit::REVIEW_FILE).display() + ) + ) ); } ReviewState::Broken { detail } => { - println!("\x1b[1;31m확인 체인 실패\x1b[0m {}", sanitize(detail)); + println!( + "{}", + tr!( + format!("\x1b[1;31m확인 체인 실패\x1b[0m {}", sanitize(detail)), + format!("\x1b[1;31mreview chain failure\x1b[0m {}", sanitize(detail)) + ) + ); } ReviewState::Ok { last, warnings } => { let color = match last.verdict { @@ -161,37 +259,83 @@ fn print_review(report: &Report) { }; let tty = match &last.reviewer_tty { Some(t) => sanitize(t), - None => "\x1b[33m미관측\x1b[0m".to_string(), + None => tr!("\x1b[33m미관측\x1b[0m", "\x1b[33munobserved\x1b[0m").to_string(), }; println!( - "마지막 확인 {} {color}{}\x1b[0m uid={} euid={} tty={tty}", - short_time(last.ts), - last.verdict.label(), - last.reviewer_uid, - last.reviewer_euid, + "{}", + tr!( + format!( + "마지막 확인 {} {color}{}\x1b[0m uid={} euid={} tty={tty}", + short_time(last.ts), + last.verdict.label(), + last.reviewer_uid, + last.reviewer_euid, + ), + format!( + "last review {} {color}{}\x1b[0m uid={} euid={} tty={tty}", + short_time(last.ts), + last.verdict.label(), + last.reviewer_uid, + last.reviewer_euid, + ) + ) ); + let range_text = match (&last.since, &last.until) { + (None, None) => tr!("전체", "all").to_string(), + (Some(a), None) => tr!( + format!("{} 이후", sanitize(a)), + format!("since {}", sanitize(a)) + ), + (None, Some(b)) => tr!( + format!("{} 까지", sanitize(b)), + format!("until {}", sanitize(b)) + ), + (Some(a), Some(b)) => format!("{} .. {}", sanitize(a), sanitize(b)), + }; println!( - " 확인 범위 {} 세션 {}개", - match (&last.since, &last.until) { - (None, None) => "전체".to_string(), - (Some(a), None) => format!("{} 이후", sanitize(a)), - (None, Some(b)) => format!("{} 까지", sanitize(b)), - (Some(a), Some(b)) => format!("{} .. {}", sanitize(a), sanitize(b)), - }, - last.sessions.len() + "{}", + tr!( + format!(" 확인 범위 {} 세션 {}개", range_text, last.sessions.len()), + format!( + " review range {} ({} sessions)", + range_text, + last.sessions.len() + ) + ) ); if let Some(n) = &last.note { - println!(" 메모 {}", sanitize(n)); + println!( + "{}", + tr!( + format!(" 메모 {}", sanitize(n)), + format!(" note {}", sanitize(n)) + ) + ); } for w in warnings { - println!(" \x1b[33m경고\x1b[0m {}", sanitize(w)); + println!( + "{}", + tr!( + format!(" \x1b[33m경고\x1b[0m {}", sanitize(w)), + format!(" \x1b[33mwarning\x1b[0m {}", sanitize(w)) + ) + ); } } } if report.sessions_after_review > 0 && !matches!(report.review, ReviewState::None) { println!( - " \x1b[33m미확인\x1b[0m 마지막 확인 이후 시작된 세션 {}개. 매일 점검이 밀려 있음", - report.sessions_after_review + "{}", + tr!( + format!( + " \x1b[33m미확인\x1b[0m 마지막 확인 이후 시작된 세션 {}개. 매일 점검이 밀려 있음", + report.sessions_after_review + ), + format!( + " \x1b[33munreviewed\x1b[0m {} sessions started after the last review; the daily check is behind", + report.sessions_after_review + ) + ) ); } } @@ -199,19 +343,43 @@ fn print_review(report: &Report) { fn print_session(s: &SessionReport) { let (mark, detail) = match &s.integrity { Integrity::Ok { entries, .. } => ( - "\x1b[32m무결성 확인\x1b[0m".to_string(), - format!("{entries} 엔트리"), + tr!("\x1b[32m무결성 확인\x1b[0m", "\x1b[32mintegrity ok\x1b[0m").to_string(), + tr!(format!("{entries} 엔트리"), format!("{entries} entries")), + ), + Integrity::Failed { detail } => ( + tr!( + "\x1b[1;31m무결성 실패\x1b[0m", + "\x1b[1;31mintegrity failure\x1b[0m" + ) + .to_string(), + sanitize(detail), ), - Integrity::Failed { detail } => { - ("\x1b[1;31m무결성 실패\x1b[0m".to_string(), sanitize(detail)) - } }; let anchor = match &s.anchor { - AnchorState::Matches { anchor_seq } => format!("앵커 seq {anchor_seq} 일치"), - AnchorState::Missing => "\x1b[33m앵커 줄 없음\x1b[0m".to_string(), - AnchorState::Mismatch { .. } => "\x1b[1;31m앵커 불일치\x1b[0m".to_string(), - AnchorState::ChainAbsent => "\x1b[33m앵커 파일 없음\x1b[0m".to_string(), - AnchorState::ChainBroken => "\x1b[1;31m앵커 체인 손상\x1b[0m".to_string(), + AnchorState::Matches { anchor_seq } => tr!( + format!("앵커 seq {anchor_seq} 일치"), + format!("anchor seq {anchor_seq} matches") + ), + AnchorState::Missing => tr!( + "\x1b[33m앵커 줄 없음\x1b[0m", + "\x1b[33mno anchor line\x1b[0m" + ) + .to_string(), + AnchorState::Mismatch { .. } => tr!( + "\x1b[1;31m앵커 불일치\x1b[0m", + "\x1b[1;31manchor mismatch\x1b[0m" + ) + .to_string(), + AnchorState::ChainAbsent => tr!( + "\x1b[33m앵커 파일 없음\x1b[0m", + "\x1b[33manchor file absent\x1b[0m" + ) + .to_string(), + AnchorState::ChainBroken => tr!( + "\x1b[1;31m앵커 체인 손상\x1b[0m", + "\x1b[1;31manchor chain damaged\x1b[0m" + ) + .to_string(), }; println!( " {} {mark} {detail} {anchor} {}", @@ -219,48 +387,111 @@ fn print_session(s: &SessionReport) { short_time(s.started_ts) ); if let Some(p) = &s.read_problem { - println!(" \x1b[1;31m읽기 문제\x1b[0m {p}"); + println!( + "{}", + tr!( + format!(" \x1b[1;31m읽기 문제\x1b[0m {p}"), + format!(" \x1b[1;31mread problem\x1b[0m {p}") + ) + ); } println!( - " 결정 allow {} deny {} ask {} forbid {}", - s.decisions.allow, s.decisions.deny, s.decisions.ask, s.decisions.forbid + "{}", + tr!( + format!( + " 결정 allow {} deny {} ask {} forbid {}", + s.decisions.allow, s.decisions.deny, s.decisions.ask, s.decisions.forbid + ), + format!( + " decisions allow {} deny {} ask {} forbid {}", + s.decisions.allow, s.decisions.deny, s.decisions.ask, s.decisions.forbid + ) + ) ); // 신원 없는 응답을 사람 승인과 같은 칸에 넣으면 --yes 자동 승인이 사람 확인처럼 읽힙니다 let unidentified = if s.approvals.auto_granted > 0 { - format!( - "\x1b[33m신원없음 {} (허용 {})\x1b[0m", - s.approvals.unidentified, s.approvals.auto_granted + tr!( + format!( + "\x1b[33m신원없음 {} (허용 {})\x1b[0m", + s.approvals.unidentified, s.approvals.auto_granted + ), + format!( + "\x1b[33munidentified {} (granted {})\x1b[0m", + s.approvals.unidentified, s.approvals.auto_granted + ) ) } else { - format!("신원없음 {}", s.approvals.unidentified) + tr!( + format!("신원없음 {}", s.approvals.unidentified), + format!("unidentified {}", s.approvals.unidentified) + ) }; println!( - " 승인 사람 {} {unidentified} (승인 {} 거부 {} 시간초과 {}) 미응답 ask {}", - s.approvals.identified, - s.approvals.approved, - s.approvals.refused, - s.approvals.timed_out, - s.approvals.unanswered + "{}", + tr!( + format!( + " 승인 사람 {} {unidentified} (승인 {} 거부 {} 시간초과 {}) 미응답 ask {}", + s.approvals.identified, + s.approvals.approved, + s.approvals.refused, + s.approvals.timed_out, + s.approvals.unanswered + ), + format!( + " approvals human {} {unidentified} (approved {} refused {} timed out {}) unanswered ask {}", + s.approvals.identified, + s.approvals.approved, + s.approvals.refused, + s.approvals.timed_out, + s.approvals.unanswered + ) + ) ); for w in &s.warnings { - println!(" \x1b[33m경고\x1b[0m {}", sanitize(w)); + println!( + "{}", + tr!( + format!(" \x1b[33m경고\x1b[0m {}", sanitize(w)), + format!(" \x1b[33mwarning\x1b[0m {}", sanitize(w)) + ) + ); } if !s.denied_exec.is_empty() { - println!(" 거부된 exec {}종", s.denied_exec.len()); + println!( + "{}", + tr!( + format!(" 거부된 exec {}종", s.denied_exec.len()), + format!(" denied exec {} kinds", s.denied_exec.len()) + ) + ); for e in s.denied_exec.iter().take(MAX_LISTED) { - let rule = e.rule.as_deref().unwrap_or("기본값"); + let rule = e.rule.as_deref().unwrap_or(tr!("기본값", "default")); println!(" {} {:?} x{} [{rule}]", e.program, e.argv, e.count); } - print_cap(s.denied_exec.len(), MAX_LISTED, "거부된 exec"); + print_cap( + s.denied_exec.len(), + MAX_LISTED, + tr!("거부된 exec", "denied exec"), + ); } if !s.destinations.is_empty() { - println!(" 아웃바운드 {}개 목적지", s.destinations.len()); + println!( + "{}", + tr!( + format!(" 아웃바운드 {}개 목적지", s.destinations.len()), + format!(" egress {} destinations", s.destinations.len()) + ) + ); for d in s.destinations.iter().take(MAX_LISTED) { println!(" {}", destination_line(d)); } - print_cap(s.destinations.len(), MAX_LISTED, "목적지"); + print_cap( + s.destinations.len(), + MAX_LISTED, + tr!("목적지", "destinations"), + ); } } @@ -269,12 +500,21 @@ fn destination_line(d: &Destination) -> String { // 가운데 결과가 남지 않은 것만 "모름" 입니다 let unknown = d.allowed.saturating_sub(d.completed); let tail = if d.completed > 0 { - format!( - " 반출 {} 수신 {} {} (연결 {})", - human_bytes(d.bytes_out), - human_bytes(d.bytes_in), - human_millis(d.duration_ms), - d.completed + tr!( + format!( + " 반출 {} 수신 {} {} (연결 {})", + human_bytes(d.bytes_out), + human_bytes(d.bytes_in), + human_millis(d.duration_ms), + d.completed + ), + format!( + " sent {} received {} {} (connections {})", + human_bytes(d.bytes_out), + human_bytes(d.bytes_in), + human_millis(d.duration_ms), + d.completed + ) ) } else { String::new() @@ -282,59 +522,144 @@ fn destination_line(d: &Destination) -> String { // 결과가 남지 않은 연결을 0 바이트로 보이게 하면 안 됩니다. 반출량을 모른다는 사실이 // 화면에 그대로 나와야 합니다 let gap = if unknown > 0 { - format!(" \x1b[33m반출량 모름 {unknown}\x1b[0m") + tr!( + format!(" \x1b[33m반출량 모름 {unknown}\x1b[0m"), + format!(" \x1b[33munknown outcome {unknown}\x1b[0m") + ) } else { String::new() }; - format!( - "{}:{} [{}] 시도 {} (허용 {} 거부 {} ask {}){tail}{gap}", - d.host, d.port, d.protocol, d.attempts, d.allowed, d.blocked, d.asked + tr!( + format!( + "{}:{} [{}] 시도 {} (허용 {} 거부 {} ask {}){tail}{gap}", + d.host, d.port, d.protocol, d.attempts, d.allowed, d.blocked, d.asked + ), + format!( + "{}:{} [{}] attempts {} (allowed {} blocked {} ask {}){tail}{gap}", + d.host, d.port, d.protocol, d.attempts, d.allowed, d.blocked, d.asked + ) ) } fn print_cap(total: usize, cap: usize, what: &str) { if total > cap { - println!(" \x1b[2m{what} {total}종 중 {cap}종만 표시. 전부 보려면 --json\x1b[0m"); + println!( + "{}", + tr!( + format!( + " \x1b[2m{what} {total}종 중 {cap}종만 표시. 전부 보려면 --json\x1b[0m" + ), + format!(" \x1b[2mshowing {cap} of {total} {what}; use --json for all\x1b[0m") + ) + ); } } fn print_totals(report: &Report) { let t = &report.totals; - println!("전체"); - println!(" 세션 {}", t.sessions); - println!(" 읽지 못함 {}", t.unreadable); - println!(" 총 차단 {}", t.blocked); - println!(" 총 ask {} (미응답 {})", t.asked, t.unanswered_asks); + println!("{}", tr!("전체", "totals")); + println!( + "{}", + tr!( + format!(" 세션 {}", t.sessions), + format!(" sessions {}", t.sessions) + ) + ); println!( - " 승인 사람 {} 신원없음 {} (그중 허용 {})", - t.identified_approvals, t.unidentified_approvals, t.auto_granted + "{}", + tr!( + format!(" 읽지 못함 {}", t.unreadable), + format!(" unreadable {}", t.unreadable) + ) + ); + println!( + "{}", + tr!( + format!(" 총 차단 {}", t.blocked), + format!(" blocked {}", t.blocked) + ) ); println!( - " 총 반출 {} / 수신 {}", - human_bytes(t.bytes_out), - human_bytes(t.bytes_in) + "{}", + tr!( + format!(" 총 ask {} (미응답 {})", t.asked, t.unanswered_asks), + format!( + " asked {} (unanswered {})", + t.asked, t.unanswered_asks + ) + ) + ); + println!( + "{}", + tr!( + format!( + " 승인 사람 {} 신원없음 {} (그중 허용 {})", + t.identified_approvals, t.unidentified_approvals, t.auto_granted + ), + format!( + " approvals human {} unidentified {} (of which granted {})", + t.identified_approvals, t.unidentified_approvals, t.auto_granted + ) + ) + ); + println!( + "{}", + tr!( + format!( + " 총 반출 {} / 수신 {}", + human_bytes(t.bytes_out), + human_bytes(t.bytes_in) + ), + format!( + " sent {} / received {}", + human_bytes(t.bytes_out), + human_bytes(t.bytes_in) + ) + ) ); if t.destinations.is_empty() { return; } let shown = t.destinations.len().min(TOP_DESTINATIONS); println!( - " 반출 상위 {shown}개 목적지 (전체 {}개)", - t.destinations.len() + "{}", + tr!( + format!( + " 반출 상위 {shown}개 목적지 (전체 {}개)", + t.destinations.len() + ), + format!( + " top {shown} destinations by sent bytes (of {} total)", + t.destinations.len() + ) + ) ); for d in t.destinations.iter().take(TOP_DESTINATIONS) { println!(" {}", destination_line(d)); } - print_cap(t.destinations.len(), TOP_DESTINATIONS, "목적지"); + print_cap( + t.destinations.len(), + TOP_DESTINATIONS, + tr!("목적지", "destinations"), + ); } fn print_anomalies(report: &Report) { if report.anomalies.is_empty() { - println!("\x1b[32m이상 없음\x1b[0m"); - println!("종료 코드 0"); + println!( + "{}", + tr!("\x1b[32m이상 없음\x1b[0m", "\x1b[32mno anomalies\x1b[0m") + ); + println!("{}", tr!("종료 코드 0", "exit code 0")); return; } - println!("\x1b[1;31m이상 {}건\x1b[0m", report.anomalies.len()); + println!( + "{}", + tr!( + format!("\x1b[1;31m이상 {}건\x1b[0m", report.anomalies.len()), + format!("\x1b[1;31m{} anomalies\x1b[0m", report.anomalies.len()) + ) + ); for a in &report.anomalies { let session = a.session.as_deref().unwrap_or("-"); println!( @@ -345,7 +670,13 @@ fn print_anomalies(report: &Report) { sanitize(&a.detail) ); } - println!("종료 코드 {}", report.exit_code()); + println!( + "{}", + tr!( + format!("종료 코드 {}", report.exit_code()), + format!("exit code {}", report.exit_code()) + ) + ); } /// SIEM 과 스크립트가 먹을 수 있는 JSON 표현을 만듭니다. @@ -358,6 +689,7 @@ pub fn json(report: &Report) -> Value { let subject = report.subject(); json!({ "schema": SCHEMA, + "locale": airlock_i18n::locale().as_str(), "generated_ts": report.generated_ts, "generated_rfc3339": airlock_audit::format_rfc3339_nanos(report.generated_ts), "root": report.root.to_string_lossy(), @@ -410,7 +742,10 @@ fn chain_json(report: &Report) -> Value { "sessions": 0, "head_seq": Value::Null, "warnings": Vec::::new(), - "detail": "anchors.jsonl 없음. 탐지 불가이며 통과가 아님", + "detail": tr!( + "anchors.jsonl 없음. 탐지 불가이며 통과가 아님", + "anchors.jsonl absent; undetectable, which is not a pass" + ), }), ChainState::Broken { detail } => json!({ "status": "broken", diff --git a/crates/airlock/src/report.rs b/crates/airlock/src/report.rs index 5e9d8c8..7b32bc9 100644 --- a/crates/airlock/src/report.rs +++ b/crates/airlock/src/report.rs @@ -28,6 +28,7 @@ use airlock_audit::{ }; use airlock_canonical::Encoder; use airlock_canonical::display::sanitize; +use airlock_i18n::tr; use crate::paths; @@ -75,8 +76,8 @@ impl Severity { pub fn label(self) -> &'static str { match self { - Self::Evidence => "증거", - Self::Operational => "운영", + Self::Evidence => tr!("증거", "evidence"), + Self::Operational => tr!("운영", "operational"), } } } @@ -197,23 +198,39 @@ fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { pub fn range_of(since: Option<&str>, until: Option<&str>) -> Result { let from = match since { None => 0, - Some(s) => parse_date(s) - .ok_or_else(|| format!("--since `{}` 를 YYYY-MM-DD 로 읽을 수 없음", sanitize(s)))?, + Some(s) => parse_date(s).ok_or_else(|| { + tr!( + format!("--since `{}` 를 YYYY-MM-DD 로 읽을 수 없음", sanitize(s)), + format!("--since `{}` cannot be read as YYYY-MM-DD", sanitize(s)) + ) + })?, }; let to = match until { None => u64::MAX, Some(s) => { let start = parse_date(s).ok_or_else(|| { - format!("--until `{}` 를 YYYY-MM-DD 로 읽을 수 없음", sanitize(s)) + tr!( + format!("--until `{}` 를 YYYY-MM-DD 로 읽을 수 없음", sanitize(s)), + format!("--until `{}` cannot be read as YYYY-MM-DD", sanitize(s)) + ) })?; start .checked_add(NANOS_PER_DAY) .and_then(|v| v.checked_sub(1)) - .ok_or_else(|| format!("--until `{}` 가 표현 범위를 넘음", sanitize(s)))? + .ok_or_else(|| { + tr!( + format!("--until `{}` 가 표현 범위를 넘음", sanitize(s)), + format!("--until `{}` exceeds the representable range", sanitize(s)) + ) + })? } }; if from > to { - return Err("--since 가 --until 보다 뒤임. 아무 세션도 덮지 않는 범위임".to_string()); + return Err(tr!( + "--since 가 --until 보다 뒤임. 아무 세션도 덮지 않는 범위임", + "--since is after --until; the range covers no sessions" + ) + .to_string()); } Ok(Range { from, to }) } @@ -466,11 +483,20 @@ impl Report { severity: Severity::Evidence, kind: "session_missing", session: Some(a.session.to_hex()), - detail: format!( - "앵커 seq {}가 가리키는 세션의 디렉토리가 {} 아래에 없음. \ - 세션 통째 삭제이거나 앵커 루트가 다른 감사 루트의 것임", - a.seq, - root.display() + detail: tr!( + format!( + "앵커 seq {}가 가리키는 세션의 디렉토리가 {} 아래에 없음. \ + 세션 통째 삭제이거나 앵커 루트가 다른 감사 루트의 것임", + a.seq, + root.display() + ), + format!( + "the directory for the session anchor seq {} points to is not \ + under {}; either whole-session deletion or the anchor root \ + belongs to a different audit root", + a.seq, + root.display() + ) ), }); } @@ -645,9 +671,16 @@ fn read_chain(anchor_dir: &Path, anomalies: &mut Vec) -> (ChainState, V severity: Severity::Evidence, kind: "anchor_absent", session: None, - detail: format!( - "{} 없음. 세션 통째 삭제와 체인 재계산을 탐지할 수 없음. 통과가 아님", - anchor_dir.join(airlock_audit::ANCHOR_FILE).display() + detail: tr!( + format!( + "{} 없음. 세션 통째 삭제와 체인 재계산을 탐지할 수 없음. 통과가 아님", + anchor_dir.join(airlock_audit::ANCHOR_FILE).display() + ), + format!( + "{} absent; whole-session deletion and chain recomputation cannot \ + be detected, which is not a pass", + anchor_dir.join(airlock_audit::ANCHOR_FILE).display() + ) ), }); (ChainState::Absent, Vec::new()) @@ -747,7 +780,10 @@ fn build_session( severity: Severity::Evidence, kind: "session_unreadable", session: Some(name.clone()), - detail: format!("세션을 읽지 못함: {e}"), + detail: tr!( + format!("세션을 읽지 못함: {e}"), + format!("failed to read the session: {e}") + ), }); return Some(SessionReport { dir: dir.to_path_buf(), @@ -795,7 +831,12 @@ fn build_session( severity: Severity::Operational, kind: "unanswered_ask", session: Some(name.clone()), - detail: format!("seq {seq}의 ask에 답이 없음. 승인 없이 끝난 요청임"), + detail: tr!( + format!("seq {seq}의 ask에 답이 없음. 승인 없이 끝난 요청임"), + format!( + "the ask at seq {seq} has no answer; a request that ended without approval" + ) + ), }); } } @@ -835,9 +876,12 @@ fn build_session( severity: Severity::Evidence, kind: "anchor_missing", session: Some(name.clone()), - detail: - "이 세션의 앵커 줄이 없음. 삭제와 재계산을 탐지할 수 없으며 통과가 아님" - .to_string(), + detail: tr!( + "이 세션의 앵커 줄이 없음. 삭제와 재계산을 탐지할 수 없으며 통과가 아님", + "this session has no anchor line; deletion and recomputation \ + cannot be detected, which is not a pass" + ) + .to_string(), }); AnchorState::Missing } @@ -953,9 +997,16 @@ fn build_session( severity: Severity::Operational, kind: "automatic_approval", session: Some(name.clone()), - detail: format!( - "{}건이 사람 신원 없이 허용됨. --yes 자동 승인이거나 신원을 관측하지 못한 채널임", - approvals.auto_granted + detail: tr!( + format!( + "{}건이 사람 신원 없이 허용됨. --yes 자동 승인이거나 신원을 관측하지 못한 채널임", + approvals.auto_granted + ), + format!( + "{} granted without a human identity; either --yes automatic approval \ + or a channel that could not observe identity", + approvals.auto_granted + ) ), }); } diff --git a/crates/airlock/tests/cli.rs b/crates/airlock/tests/cli.rs index 0cfabe9..4648cb7 100644 --- a/crates/airlock/tests/cli.rs +++ b/crates/airlock/tests/cli.rs @@ -66,6 +66,7 @@ fn airlock(s: &Scratch, cwd: &Path, args: &[&str]) -> Output { .args(args) .current_dir(cwd) .env("HOME", &home) + .env("AIRLOCK_LANG", "ko") .env_remove("AIRLOCK_AUDIT_DIR") .env_remove("XDG_DATA_HOME") .output() From 0f1d6500d5ac6e355b4df0ab7dad6808f980c7ed Mon Sep 17 00:00:00 2001 From: Quant-TheodoreFelix <53819958+Quant-TheodoreFelix@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:49:02 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=EC=84=A4=EC=B9=98=20=EB=A7=88=EB=B2=95?= =?UTF-8?q?=EC=82=AC=EC=97=90=20=EC=96=B8=EC=96=B4=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EC=99=80=20=EC=98=81=EB=AC=B8=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=EC=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첫 질문이 출력 언어이고 고른 값을 구성 파일에 저장해 다음 실행부터 적용됨 - 프리셋과 직접 설정 산출물의 주석이 고른 언어로 나감 - 영문 프리셋 세 벌을 넣고 한국어판과 다이제스트가 같음을 테스트로 강제 - `check.sh` 가 영문 프리셋도 검증하고 패키징 게이트를 무의존 리프로 옮김 Co-authored-by: Claude Fable 5 --- crates/airlock-setup/Cargo.toml | 1 + .../airlock-setup/presets/en/claude-code.toml | 221 +++++++++++ .../airlock-setup/presets/en/developer.toml | 49 +++ crates/airlock-setup/presets/en/strict.toml | 364 ++++++++++++++++++ crates/airlock-setup/src/custom.rs | 51 ++- crates/airlock-setup/src/emit.rs | 33 +- crates/airlock-setup/src/error.rs | 40 +- crates/airlock-setup/src/presets.rs | 96 ++++- crates/airlock-setup/src/wizard.rs | 317 +++++++++++---- examples/policy/en/claude-code.toml | 221 +++++++++++ examples/policy/en/developer.toml | 49 +++ examples/policy/en/strict.toml | 364 ++++++++++++++++++ scripts/check.sh | 9 +- 13 files changed, 1692 insertions(+), 123 deletions(-) create mode 100644 crates/airlock-setup/presets/en/claude-code.toml create mode 100644 crates/airlock-setup/presets/en/developer.toml create mode 100644 crates/airlock-setup/presets/en/strict.toml create mode 100644 examples/policy/en/claude-code.toml create mode 100644 examples/policy/en/developer.toml create mode 100644 examples/policy/en/strict.toml diff --git a/crates/airlock-setup/Cargo.toml b/crates/airlock-setup/Cargo.toml index ca62a31..4ddacf8 100644 --- a/crates/airlock-setup/Cargo.toml +++ b/crates/airlock-setup/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true description = "airlock 정책 파일을 만드는 대화형 마법사" [dependencies] +airlock-i18n.workspace = true airlock-policy.workspace = true cliclack.workspace = true console.workspace = true diff --git a/crates/airlock-setup/presets/en/claude-code.toml b/crates/airlock-setup/presets/en/claude-code.toml new file mode 100644 index 0000000..3508e43 --- /dev/null +++ b/crates/airlock-setup/presets/en/claude-code.toml @@ -0,0 +1,221 @@ +# A policy for running Claude Code under airlock. +# +# airlock run --policy examples/policy/claude-code.toml --workspace ~/work/myproj -- claude +# +# The built-in baseline has no egress allow rules at all. So if you run +# airlock run -- claude without a policy file, outbound is shut down entirely, and +# Claude Code cannot even find api.anthropic.com over DNS and dies with +# ENOTFOUND. This file writes down the minimal set that must be opened then. +# +# Watch out! macOS Seatbelt cannot express host-level egress. If even one egress +# allow exists below, the kernel profile opens outbound entirely. That is, this +# host list is a declaration of intent at the policy layer, not an enforced boundary +# on macOS. Linux Landlock also enforces only down to the port. Real host enforcement +# only appears once the egress proxy layer lands (docs/design.md 10.1). airlock +# exposes this fact as-is in the banner's limitations list. + +version = 1 +name = "claude-code" + +[defaults] +file = "deny" +exec = "allow" +egress = "deny" # allow is forbidden at the grammar level +# Same as the default, but stated explicitly. A preset is a document that shows the spec to humans. +# The egress allow rules below are all 443 and state no protocol. A protocol condition only +# gains meaning with an egress proxy layer attached; in a session without one the rule is dead entirely. +egress_plaintext = "deny" + +# Even though the exec default is allow, the baseline's risky exec ask rules stay +# alive. Because this opens via [defaults] (tier 4), not a user rule (tier 2), +# tier 3 ask rules such as sudo, rm -rf, and curl pipe shell match first. + +# +# Workspace +# +# Replace this path with your actual project and pass the same path to --workspace. +# The --workspace flag opens the kernel profile's write window; the rule below is the policy layer's verdict. +[[rules]] +id = "workspace" +kind = "file" +path = "~/work/**" +action = "allow" + +# +# Toolchain read +# +# The compilers and CLIs the agent invokes live here. Writing is not opened. +# /etc/** is deliberately left out. It would put a user rule in the position of +# touching /etc/shadow and /etc/sudoers, which the baseline catches as forbid. +[[rules]] +id = "toolchain-read" +kind = "file" +path = ["/usr/**", "/bin/**", "/sbin/**", "/opt/homebrew/**", "/System/**", "/Library/**"] +mode = ["read", "metadata", "exec"] +action = "allow" + +# +# Claude Code installation +# +# This assumes a native install. For a global npm install, enter that path instead. +[[rules]] +id = "claude-install" +kind = "file" +path = ["~/.local/share/claude/**", "~/.local/bin/**"] +mode = ["read", "metadata", "exec"] +action = "allow" + +# +# Claude Code state +# +# Sessions, history, settings, and shell snapshots all live here, so writing is needed. +# ~/.claude.json holds the MCP server list and project state. +[[rules]] +id = "claude-state" +kind = "file" +path = ["~/.claude/**", "~/.claude.json*"] +action = "allow" + +# +# Build caches +# +# Without these, cargo and npm fail every time. +[[rules]] +id = "build-cache" +kind = "file" +path = ["~/.cargo/registry/**", "~/.cargo/git/**", "~/.rustup/**", "~/.npm/_cacache/**"] +action = "allow" + +# +# Temporary files +# +# On macOS, /tmp is a symbolic link to /private/tmp. The policy evaluates both +# the requested path and the resolved path (docs/policy-dsl.md 4.1), so both are listed. +[[rules]] +id = "temp-files" +kind = "file" +path = ["/tmp/**", "/private/tmp/**", "/private/var/folders/**"] +action = "allow" + +# +# Login credentials +# +# Claude Code puts its OAuth token in the "Claude Code-credentials" entry of the +# macOS keychain. Without this rule the process starts but stalls at 'Not logged in'. +# +# Warning! A path-based policy cannot express "only that one entry". That is, this +# rule lets the agent read the entire login keychain. securityd's ACLs judge each +# entry's actual access again, but the boundary airlock provides stops at the file level. +# +# The data protection keychain (~/Library/Keychains//) is deliberately left +# out. iCloud and Safari credentials live there and Claude Code does not use it. +# +# If you pass ANTHROPIC_API_KEY as an environment variable, there is no need to open +# the keychain at all. For an agent running inside a sandbox, that is the better choice. +[[rules]] +id = "keychain-login-ro" +kind = "file" +path = "~/Library/Keychains/login.keychain-db" +mode = ["read", "metadata"] +action = "allow" + +# +# Anthropic endpoints +# +# Up to here is the minimum Claude Code needs to start, log in, and converse. +[[rules]] +id = "anthropic-api" +kind = "egress" +host = "api.anthropic.com" +port = 443 +action = "allow" + +[[rules]] +id = "claude-code-service" +kind = "egress" +host = "code.claude.com" +port = 443 +action = "allow" + +[[rules]] +id = "claude-platform" +kind = "egress" +host = "platform.claude.com" +port = 443 +action = "allow" + +# OAuth login flow +[[rules]] +id = "claude-login" +kind = "egress" +host = "claude.ai" +port = 443 +action = "allow" + +[[rules]] +id = "anthropic-console-login" +kind = "egress" +host = "console.anthropic.com" +port = 443 +action = "allow" + +# Auto updates. +# If autoUpdates is turned off, this is not really needed. +[[rules]] +id = "claude-downloads" +kind = "egress" +host = "downloads.claude.ai" +port = 443 +action = "allow" + +# +# Development tool endpoints +# +# Needed only when you have the agent run git and install packages. Delete these if unused. +# *.githubusercontent.com matches only subdomains, never the apex +# (docs/policy-dsl.md section 8). raw and objects are what get filtered here. +[[rules]] +id = "github-web" +kind = "egress" +host = "github.com" +port = 443 +action = "allow" + +[[rules]] +id = "github-api" +kind = "egress" +host = "api.github.com" +port = 443 +action = "allow" + +[[rules]] +id = "github-content" +kind = "egress" +host = "*.githubusercontent.com" +port = 443 +action = "allow" + +[[rules]] +id = "npm-registry" +kind = "egress" +host = "registry.npmjs.org" +port = 443 +action = "allow" + +# +# Disabled entries +# +# ~/.config/gh/hosts.yml holds a GitHub token in plaintext. Allowing reads makes the +# gh CLI work, but that token immediately becomes an exfiltratable credential. If you +# need it, use the rules below, but enable them only after understanding exactly what they mean. +# +# [[rules]] +# id = "gh-token" +# kind = "file" +# path = "~/.config/gh/**" +# mode = ["read", "metadata"] +# action = "allow" +# +# ~/.ssh and ~/.aws are baseline forbid, so they cannot be opened here. If you +# really need them, you must name those rule ids in overrides and write a reason +# (docs/policy-dsl.md 3.2). For git push, HTTPS is a better choice than SSH. diff --git a/crates/airlock-setup/presets/en/developer.toml b/crates/airlock-setup/presets/en/developer.toml new file mode 100644 index 0000000..d88cb64 --- /dev/null +++ b/crates/airlock-setup/presets/en/developer.toml @@ -0,0 +1,49 @@ +# The developer convenience form. Only secrets are blocked and the rest passes through. +# The built-in baseline keeps catching secrets and persistence paths. +# +# defaults.file = "allow" below is the policy layer's verdict. The kernel enforcement +# profile defaults to deny, so outside the workspace and system paths nothing opens unless +# a rule states it. That is, ordinary files outside the workspace are blocked under +# airlock run and are read as the policy decides only under --observe. The kernel being +# narrower than the policy is safe, but paths you actually need to read must be written as allow rules. + +version = 1 +name = "developer" + +[defaults] +file = "allow" +exec = "allow" +egress = "deny" +# Same as the default, but stated explicitly. A preset is a document that shows the spec to humans. +# Even with file set to allow, plaintext outbound cannot cross this floor. +egress_plaintext = "deny" + +# System paths are dropped to read-only +[[rules]] +id = "system-readonly" +kind = "file" +path = ["/usr/**", "/System/**", "/bin/**", "/sbin/**", "/Library/**"] +mode = ["read", "metadata", "exec"] +action = "allow" + +[[rules]] +id = "system-write-guard" +kind = "file" +path = ["/usr/**", "/System/**", "/bin/**", "/sbin/**", "/Library/**"] +mode = ["write", "create", "delete"] +action = "ask" + +# Force push is hard to undo +[[rules]] +id = "git-force-push" +kind = "exec" +program = "git" +argv_contains = ["push", "--force"] +action = "ask" + +[[rules]] +id = "llm-api" +kind = "egress" +host = "api.foollm.com" +port = 443 +action = "allow" diff --git a/crates/airlock-setup/presets/en/strict.toml b/crates/airlock-setup/presets/en/strict.toml new file mode 100644 index 0000000..55d2108 --- /dev/null +++ b/crates/airlock-setup/presets/en/strict.toml @@ -0,0 +1,364 @@ +# The zero trust baseline form. Block everything outside the workspace and pass only what is needed. +# Validate with airlock policy check --policy examples/policy/strict.toml. +# +# Since [defaults].exec is not allow, exec becomes a kernel whitelist. Only the programs +# and top-level commands written in the kind = "exec" allow rules below run; the kernel +# refuses everything else. If a program you need is missing, add that line. The point of +# this preset is that widening is easy, but the fact that you widened stays in the policy file. + +version = 1 +name = "strict" + +[defaults] +file = "deny" +exec = "ask" +egress = "deny" +# Same as the default, but stated explicitly. A preset is a document that shows the spec to humans. +# Plaintext outbound cannot cross this floor even toward allowed hosts. +egress_plaintext = "deny" + +# Workspace +[[rules]] +id = "workspace" +kind = "file" +path = "~/work/**" +action = "allow" + +# Toolchain read: needed to read executables and libraries. +# +# It matters that mode carries no exec. Granting exec here would widen the +# whitelist to all of /usr, /bin, /System, and /Library, making virtually the +# whole system executable. Execution is opened only by the kind = "exec" rules below. +[[rules]] +id = "toolchain-read" +kind = "file" +path = ["/usr/**", "/bin/**", "/opt/homebrew/**", "/System/**", "/Library/**"] +mode = ["read", "metadata"] +action = "allow" + +# Build caches: writing is needed +[[rules]] +id = "build-cache" +kind = "file" +path = ["~/.cargo/registry/**", "~/.cargo/git/**", "~/.rustup/**", "~/.npm/_cacache/**"] +action = "allow" + +# +# Exec allow list +# +# Writing only a name in program opens just the path it resolves to on the current PATH. +# Writing a file with the same name into the workspace does not make it run. + +# Shells +# Without a shell the agent cannot run a single command. macOS /bin/sh re-execs +# itself as /bin/bash through the dyld variant mechanism, so both are needed +# (docs/limitations.md 4.13). +[[rules]] +id = "exec-sh" +kind = "exec" +program = "sh" +action = "allow" + +[[rules]] +id = "exec-bash" +kind = "exec" +program = "bash" +action = "allow" + +[[rules]] +id = "exec-zsh" +kind = "exec" +program = "zsh" +action = "allow" + +# Exec helpers +# env and xargs are conduits that exec other programs on one's behalf. Once opened, +# whatever launches through them is judged again by this list. which is for path lookup. +[[rules]] +id = "exec-env" +kind = "exec" +program = "env" +action = "allow" + +[[rules]] +id = "exec-xargs" +kind = "exec" +program = "xargs" +action = "allow" + +[[rules]] +id = "exec-which" +kind = "exec" +program = "which" +action = "allow" + +# File manipulation +# The minimal set the agent uses to read and modify the workspace. +[[rules]] +id = "exec-ls" +kind = "exec" +program = "ls" +action = "allow" + +[[rules]] +id = "exec-cat" +kind = "exec" +program = "cat" +action = "allow" + +[[rules]] +id = "exec-cp" +kind = "exec" +program = "cp" +action = "allow" + +[[rules]] +id = "exec-mv" +kind = "exec" +program = "mv" +action = "allow" + +[[rules]] +id = "exec-rm" +kind = "exec" +program = "rm" +action = "allow" + +[[rules]] +id = "exec-mkdir" +kind = "exec" +program = "mkdir" +action = "allow" + +[[rules]] +id = "exec-ln" +kind = "exec" +program = "ln" +action = "allow" + +[[rules]] +id = "exec-chmod" +kind = "exec" +program = "chmod" +action = "allow" + +[[rules]] +id = "exec-touch" +kind = "exec" +program = "touch" +action = "allow" + +[[rules]] +id = "exec-stat" +kind = "exec" +program = "stat" +action = "allow" + +[[rules]] +id = "exec-find" +kind = "exec" +program = "find" +action = "allow" + +# Text processing +# Used for code search and editing. +[[rules]] +id = "exec-grep" +kind = "exec" +program = "grep" +action = "allow" + +[[rules]] +id = "exec-sed" +kind = "exec" +program = "sed" +action = "allow" + +[[rules]] +id = "exec-awk" +kind = "exec" +program = "awk" +action = "allow" + +[[rules]] +id = "exec-head" +kind = "exec" +program = "head" +action = "allow" + +[[rules]] +id = "exec-tail" +kind = "exec" +program = "tail" +action = "allow" + +[[rules]] +id = "exec-sort" +kind = "exec" +program = "sort" +action = "allow" + +[[rules]] +id = "exec-uniq" +kind = "exec" +program = "uniq" +action = "allow" + +[[rules]] +id = "exec-wc" +kind = "exec" +program = "wc" +action = "allow" + +[[rules]] +id = "exec-cut" +kind = "exec" +program = "cut" +action = "allow" + +[[rules]] +id = "exec-tr" +kind = "exec" +program = "tr" +action = "allow" + +[[rules]] +id = "exec-diff" +kind = "exec" +program = "diff" +action = "allow" + +[[rules]] +id = "exec-tee" +kind = "exec" +program = "tee" +action = "allow" + +[[rules]] +id = "exec-basename" +kind = "exec" +program = "basename" +action = "allow" + +[[rules]] +id = "exec-dirname" +kind = "exec" +program = "dirname" +action = "allow" + +# Miscellaneous +# Things scripts commonly invoke. +[[rules]] +id = "exec-echo" +kind = "exec" +program = "echo" +action = "allow" + +[[rules]] +id = "exec-date" +kind = "exec" +program = "date" +action = "allow" + +[[rules]] +id = "exec-sleep" +kind = "exec" +program = "sleep" +action = "allow" + +[[rules]] +id = "exec-uname" +kind = "exec" +program = "uname" +action = "allow" + +# Toolchain +# Delete the lines for languages you do not use. A name absent from PATH shows +# up in the banner as a limitation, and the kernel refuses to execute that program. +[[rules]] +id = "exec-git" +kind = "exec" +program = "git" +action = "allow" + +[[rules]] +id = "exec-cargo" +kind = "exec" +program = "cargo" +action = "allow" + +[[rules]] +id = "exec-rustc" +kind = "exec" +program = "rustc" +action = "allow" + +[[rules]] +id = "exec-make" +kind = "exec" +program = "make" +action = "allow" + +[[rules]] +id = "exec-cc" +kind = "exec" +program = "cc" +action = "allow" + +[[rules]] +id = "exec-python3" +kind = "exec" +program = "python3" +action = "allow" + +[[rules]] +id = "exec-node" +kind = "exec" +program = "node" +action = "allow" + +[[rules]] +id = "exec-npm" +kind = "exec" +program = "npm" +action = "allow" + +# macOS developer tool shims +# +# On macOS, /usr/bin/git, /usr/bin/make, /usr/bin/cc, and /usr/bin/python3 are not the +# real programs but shims that re-exec into /Library/Developer/CommandLineTools. With the +# name rules above alone, that re-exec is blocked in the kernel and the program never +# starts at all (the same nature as the /bin/sh dyld variant, docs/limitations.md 4.13). +# +# Opening only usr/bin is not enough. Entries inside it, like python3, are symbolic links +# pointing at the real files on the framework side, and the kernel judges by the resolved +# path. So we open the whole CommandLineTools tree in one piece. +# +# This rule also opens the other executables inside the developer tool tree. Even so, it +# is far narrower than opening all of /usr, /System, and /Library, and tools used for +# exfiltration such as curl, nc, ssh, and osascript remain outside. If you use Xcode.app, +# you must add /Applications/Xcode.app/Contents/Developer/** the same way. Linux has no +# such path, so this opens nothing there. +[[rules]] +id = "exec-macos-developer-tools" +kind = "exec" +program = "/Library/Developer/CommandLineTools/**" +action = "allow" + +# +# Outbound +# +# Only actually enforced per host once the egress proxy layer is attached. +[[rules]] +id = "llm-api" +kind = "egress" +host = "api.foollm.com" +port = 443 +action = "allow" + +[[rules]] +id = "crates-io" +kind = "egress" +host = "*.crates.io" +port = 443 +action = "allow" diff --git a/crates/airlock-setup/src/custom.rs b/crates/airlock-setup/src/custom.rs index 2882a3d..db6cb3b 100644 --- a/crates/airlock-setup/src/custom.rs +++ b/crates/airlock-setup/src/custom.rs @@ -1,16 +1,28 @@ +use airlock_i18n::Locale; use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, value}; -const HEADER: &str = "\ +const HEADER_KO: &str = "\ # airlock setup 대화형 직접 설정으로 생성한 정책입니다. # 규격은 docs/policy-dsl.md, 작성 가이드는 docs/policy-guide.md를 참고하세요. "; -const EGRESS_CAVEAT: &str = "\ +const HEADER_EN: &str = "\ +# airlock setup generated this policy through the interactive custom flow. +# See docs/policy-dsl.md for the format and docs/policy-guide.md for guidance. +"; + +const EGRESS_CAVEAT_KO: &str = "\ # 주의! 호스트 단위 egress 규칙은 airlock run --egress-proxy로 실행할 때만 # 강제됩니다. macOS Seatbelt와 Linux Landlock은 호스트를 구분하지 못합니다 # (docs/egress-proxy.md). "; +const EGRESS_CAVEAT_EN: &str = "\ +# Watch out! Host-level egress rules are only enforced when running with +# airlock run --egress-proxy. macOS Seatbelt and Linux Landlock cannot +# tell hosts apart (docs/egress-proxy.md). +"; + const TOOLCHAIN_PATHS: &[&str] = &[ "/usr/**", "/bin/**", @@ -38,7 +50,7 @@ pub struct CustomAnswers { pub egress_hosts: Vec<(String, u16)>, } -pub fn render(answers: &CustomAnswers) -> String { +pub fn render(answers: &CustomAnswers, locale: Locale) -> String { let mut doc = DocumentMut::new(); doc["version"] = value(1); doc["name"] = value(answers.name.as_str()); @@ -71,9 +83,15 @@ pub fn render(answers: &CustomAnswers) -> String { } doc["rules"] = Item::ArrayOfTables(rules); - let mut out = String::from(HEADER); + let mut out = String::from(match locale { + Locale::Ko => HEADER_KO, + Locale::En => HEADER_EN, + }); if !answers.egress_hosts.is_empty() { - out.push_str(EGRESS_CAVEAT); + out.push_str(match locale { + Locale::Ko => EGRESS_CAVEAT_KO, + Locale::En => EGRESS_CAVEAT_EN, + }); } out.push('\n'); out.push_str(&doc.to_string()); @@ -161,7 +179,7 @@ mod tests { #[test] fn rendered_policy_loads() { - let rendered = render(&answers()); + let rendered = render(&answers(), Locale::Ko); let ctx = LoadContext::new("/home/tester", "/home/tester/.local/share/airlock"); let policy = Policy::load_str(&rendered, &ctx).expect("load"); assert_eq!(policy.name(), "my-project"); @@ -169,7 +187,7 @@ mod tests { #[test] fn contains_expected_rules_and_header() { - let rendered = render(&answers()); + let rendered = render(&answers(), Locale::Ko); assert!(rendered.starts_with("# airlock setup")); assert!(rendered.contains("--egress-proxy")); assert!(rendered.contains(r#"path = "~/proj/demo/**""#)); @@ -189,13 +207,30 @@ mod tests { egress_default: "ask".into(), ..answers() }; - let rendered = render(&a); + let rendered = render(&a, Locale::Ko); assert!(!rendered.contains("toolchain-read")); assert!(!rendered.contains("--egress-proxy")); let ctx = LoadContext::new("/home/tester", "/home/tester/.local/share/airlock"); Policy::load_str(&rendered, &ctx).expect("load"); } + #[test] + fn english_locale_renders_english_comments_and_same_policy() { + let ctx = LoadContext::new("/home/tester", "/home/tester/.local/share/airlock"); + let ko = render(&answers(), Locale::Ko); + let en = render(&answers(), Locale::En); + assert!(en.starts_with("# airlock setup")); + assert!(en.contains("# Watch out!")); + assert!(!en.contains("주의")); + let ko_policy = Policy::load_str(&ko, &ctx).expect("ko"); + let en_policy = Policy::load_str(&en, &ctx).expect("en"); + assert_eq!( + ko_policy.digest(), + en_policy.digest(), + "로케일이 정책 의미를 바꾸면 안 됨" + ); + } + #[test] fn slug_produces_valid_rule_ids() { for (host, port, want) in [ diff --git a/crates/airlock-setup/src/emit.rs b/crates/airlock-setup/src/emit.rs index 757b5a7..0e3d30c 100644 --- a/crates/airlock-setup/src/emit.rs +++ b/crates/airlock-setup/src/emit.rs @@ -21,6 +21,7 @@ pub fn render(preset_source: &str, workspace: Option<&str>) -> Result) -> fmt::Result { match self { - Self::NoTty => write!(f, "대화형 터미널(TTY)에서만 실행할 수 있음"), - Self::UnknownPreset(id) => write!(f, "알 수 없는 프리셋: {id}"), - Self::Io(e) => write!(f, "입출력 오류: {e}"), - Self::Toml(e) => write!(f, "프리셋 TOML 오류: {e}"), - Self::Policy(e) => write!(f, "정책 검증 실패: {e}"), + Self::NoTty => f.write_str(tr!( + "대화형 터미널(TTY)에서만 실행할 수 있음", + "can only run on an interactive terminal (TTY)" + )), + Self::UnknownPreset(id) => write!( + f, + "{}", + tr!( + format!("알 수 없는 프리셋: {id}"), + format!("unknown preset: {id}") + ) + ), + Self::Io(e) => write!( + f, + "{}", + tr!(format!("입출력 오류: {e}"), format!("I/O error: {e}")) + ), + Self::Toml(e) => write!( + f, + "{}", + tr!( + format!("프리셋 TOML 오류: {e}"), + format!("preset TOML error: {e}") + ) + ), + Self::Policy(e) => write!( + f, + "{}", + tr!( + format!("정책 검증 실패: {e}"), + format!("policy validation failed: {e}") + ) + ), } } } diff --git a/crates/airlock-setup/src/presets.rs b/crates/airlock-setup/src/presets.rs index 3ce3f76..7135ca0 100644 --- a/crates/airlock-setup/src/presets.rs +++ b/crates/airlock-setup/src/presets.rs @@ -1,33 +1,70 @@ +use airlock_i18n::Locale; + #[derive(Debug, Clone, Copy)] pub struct Preset { pub id: &'static str, pub label: &'static str, - pub hint: &'static str, - pub command_hint: &'static str, - pub source: &'static str, + pub hint_ko: &'static str, + pub hint_en: &'static str, + pub command_hint_ko: &'static str, + pub command_hint_en: &'static str, + pub source_ko: &'static str, + pub source_en: &'static str, +} + +impl Preset { + pub fn hint(&self, locale: Locale) -> &'static str { + match locale { + Locale::Ko => self.hint_ko, + Locale::En => self.hint_en, + } + } + + pub fn command_hint(&self, locale: Locale) -> &'static str { + match locale { + Locale::Ko => self.command_hint_ko, + Locale::En => self.command_hint_en, + } + } + + pub fn source(&self, locale: Locale) -> &'static str { + match locale { + Locale::Ko => self.source_ko, + Locale::En => self.source_en, + } + } } pub const PRESETS: &[Preset] = &[ Preset { id: "claude-code", label: "Claude Code", - hint: "Claude Code 구동에 필요한 최소 개방", - command_hint: "claude", - source: include_str!("../presets/claude-code.toml"), + hint_ko: "Claude Code 구동에 필요한 최소 개방", + hint_en: "the minimum opening Claude Code needs to run", + command_hint_ko: "claude", + command_hint_en: "claude", + source_ko: include_str!("../presets/claude-code.toml"), + source_en: include_str!("../presets/en/claude-code.toml"), }, Preset { id: "strict", label: "Strict", - hint: "기본 전부 deny, 명시한 것만 허용", - command_hint: "<에이전트 명령>", - source: include_str!("../presets/strict.toml"), + hint_ko: "기본 전부 deny, 명시한 것만 허용", + hint_en: "deny everything by default, allow only what is listed", + command_hint_ko: "<에이전트 명령>", + command_hint_en: "", + source_ko: include_str!("../presets/strict.toml"), + source_en: include_str!("../presets/en/strict.toml"), }, Preset { id: "developer", label: "Developer", - hint: "파일 기본 allow, 위험 지점만 ask", - command_hint: "<에이전트 명령>", - source: include_str!("../presets/developer.toml"), + hint_ko: "파일 기본 allow, 위험 지점만 ask", + hint_en: "files allow by default, ask only at risky spots", + command_hint_ko: "<에이전트 명령>", + command_hint_en: "", + source_ko: include_str!("../presets/developer.toml"), + source_en: include_str!("../presets/en/developer.toml"), }, ]; @@ -47,8 +84,25 @@ mod tests { fn all_presets_parse_and_name_matches_id() { let ctx = LoadContext::new("/home/tester", "/home/tester/.local/share/airlock"); for preset in PRESETS { - let policy = Policy::load_str(preset.source, &ctx).expect(preset.id); - assert_eq!(policy.name(), preset.id); + for locale in [Locale::Ko, Locale::En] { + let policy = Policy::load_str(preset.source(locale), &ctx).expect(preset.id); + assert_eq!(policy.name(), preset.id); + } + } + } + + #[test] + fn en_presets_only_differ_in_comments() { + let ctx = LoadContext::new("/home/tester", "/home/tester/.local/share/airlock"); + for preset in PRESETS { + let ko = Policy::load_str(preset.source_ko, &ctx).expect(preset.id); + let en = Policy::load_str(preset.source_en, &ctx).expect(preset.id); + assert_eq!( + ko.digest(), + en.digest(), + "{} 영문 프리셋의 의미가 한국어판과 어긋남", + preset.id + ); } } @@ -59,13 +113,13 @@ mod tests { return; } for preset in PRESETS { - let example = std::fs::read_to_string(examples.join(format!("{}.toml", preset.id))) - .expect(preset.id); - assert_eq!( - example, preset.source, - "{} 프리셋이 examples/policy와 어긋남", - preset.id - ); + for (source, path) in [ + (preset.source_ko, format!("{}.toml", preset.id)), + (preset.source_en, format!("en/{}.toml", preset.id)), + ] { + let example = std::fs::read_to_string(examples.join(&path)).expect(&path); + assert_eq!(example, source, "{path} 프리셋이 examples/policy와 어긋남"); + } } } } diff --git a/crates/airlock-setup/src/wizard.rs b/crates/airlock-setup/src/wizard.rs index dfdaeed..ef86475 100644 --- a/crates/airlock-setup/src/wizard.rs +++ b/crates/airlock-setup/src/wizard.rs @@ -1,6 +1,7 @@ use std::io::IsTerminal; use std::path::{Path, PathBuf}; +use airlock_i18n::{Locale, tr}; use airlock_policy::{LoadContext, Policy}; use crate::custom::{self, CustomAnswers}; @@ -36,14 +37,14 @@ impl Plan { fn label(&self) -> &str { match self { Self::Preset { preset, .. } => preset.label, - Self::Custom(_) => "직접 설정", + Self::Custom(_) => tr!("직접 설정", "custom"), } } fn command_hint(&self) -> &str { match self { - Self::Preset { preset, .. } => preset.command_hint, - Self::Custom(_) => "<에이전트 명령>", + Self::Preset { preset, .. } => preset.command_hint(airlock_i18n::locale()), + Self::Custom(_) => tr!("<에이전트 명령>", ""), } } @@ -55,9 +56,12 @@ impl Plan { } fn render(&self) -> Result { + let locale = airlock_i18n::locale(); match self { - Self::Preset { preset, workspace } => emit::render(preset.source, Some(workspace)), - Self::Custom(answers) => Ok(custom::render(answers)), + Self::Preset { preset, workspace } => { + emit::render(preset.source(locale), Some(workspace)) + } + Self::Custom(answers) => Ok(custom::render(answers, locale)), } } } @@ -69,7 +73,7 @@ pub fn run(opts: &SetupOptions) -> Result { cliclack::set_theme(AirlockTheme); match flow(opts) { Err(SetupError::Io(e)) if e.kind() == std::io::ErrorKind::Interrupted => { - let _ = cliclack::outro_cancel("설정을 중단했습니다"); + let _ = cliclack::outro_cancel(tr!("설정을 중단했습니다", "setup cancelled")); Ok(Outcome::Cancelled) } other => other, @@ -78,7 +82,13 @@ pub fn run(opts: &SetupOptions) -> Result { fn flow(opts: &SetupOptions) -> Result { cliclack::intro(theme::title())?; - cliclack::log::remark("질문 몇 개로 정책 파일(airlock.toml)을 만듭니다")?; + + ask_locale()?; + + cliclack::log::remark(tr!( + "질문 몇 개로 정책 파일(airlock.toml)을 만듭니다", + "a few questions will produce a policy file (airlock.toml)" + ))?; let plan = ask_plan(opts)?; @@ -87,58 +97,128 @@ fn flow(opts: &SetupOptions) -> Result { None => opts.cwd.join("airlock.toml"), }; if out.exists() && !confirm_overwrite(&out)? { - cliclack::outro_cancel("기존 정책을 보존하고 종료합니다")?; + cliclack::outro_cancel(tr!( + "기존 정책을 보존하고 종료합니다", + "keeping the existing policy and exiting" + ))?; return Ok(Outcome::Cancelled); } let spinner = cliclack::spinner(); - spinner.start("정책을 생성하고 검증하는 중"); + spinner.start(tr!( + "정책을 생성하고 검증하는 중", + "generating and validating the policy" + )); let (rendered, warnings) = match build(&plan, opts) { Ok(v) => v, Err(e) => { - spinner.error("정책 생성 실패"); + spinner.error(tr!("정책 생성 실패", "policy generation failed")); return Err(e); } }; - spinner.stop("정책 검증 통과"); + spinner.stop(tr!("정책 검증 통과", "policy validated")); const MAX_WARNINGS: usize = 3; for warning in warnings.iter().take(MAX_WARNINGS) { cliclack::log::warning(warning)?; } if warnings.len() > MAX_WARNINGS { - cliclack::log::remark(format!( - "비슷한 경고 {}건 생략함. 전체는 airlock policy check로 확인하세요", - warnings.len() - MAX_WARNINGS + cliclack::log::remark(tr!( + format!( + "비슷한 경고 {}건 생략함. 전체는 airlock policy check로 확인하세요", + warnings.len() - MAX_WARNINGS + ), + format!( + "{} similar warning(s) omitted; see them all with airlock policy check", + warnings.len() - MAX_WARNINGS + ) ))?; } std::fs::write(&out, &rendered)?; - cliclack::log::success(format!("{} 작성됨", out.display()))?; + cliclack::log::success(tr!( + format!("{} 작성됨", out.display()), + format!("wrote {}", out.display()) + ))?; cliclack::note( - "설정 요약", - format!( - "구성: {}\n정책 파일: {}\n작업 공간: {}", - plan.label(), - out.display(), - plan.workspace() + tr!("설정 요약", "setup summary"), + tr!( + format!( + "구성: {}\n정책 파일: {}\n작업 공간: {}", + plan.label(), + out.display(), + plan.workspace() + ), + format!( + "profile: {}\npolicy file: {}\nworkspace: {}", + plan.label(), + out.display(), + plan.workspace() + ) ), )?; - cliclack::outro(format!( - "바로 실행: {}", - theme::command(&run_hint(&plan, &out, opts)) + cliclack::outro(tr!( + format!( + "바로 실행: {}", + theme::command(&run_hint(&plan, &out, opts)) + ), + format!( + "run it now: {}", + theme::command(&run_hint(&plan, &out, opts)) + ) ))?; Ok(Outcome::Written(out)) } -fn ask_plan(opts: &SetupOptions) -> Result { - let mut items: Vec<(&str, &str, &str)> = - PRESETS.iter().map(|p| (p.id, p.label, p.hint)).collect(); - items.push(("custom", "직접 설정", "프리셋 없이 질문으로 처음부터 구성")); - let id = cliclack::select("어떤 방식으로 시작할까요?") - .items(&items) +/// 출력 언어를 묻고 즉시 적용한 뒤 전역 구성 파일에 남깁니다. +/// +/// 첫 질문이어야 나머지 질문이 고른 언어로 나갑니다. 저장 실패는 마법사를 멈출 일이 +/// 아니므로 경고로만 남깁니다 +fn ask_locale() -> Result { + let current = airlock_i18n::locale(); + let picked = cliclack::select("Language · 언어") + .items(&[("ko", "한국어", ""), ("en", "English", "")]) + .initial_value(current.as_str()) .interact()?; + let locale = Locale::parse(picked).unwrap_or_default(); + airlock_i18n::set_locale(locale); + + if locale != current || airlock_i18n::config_path().is_none_or(|p| !p.exists()) { + match airlock_i18n::save_locale(locale) { + Ok(path) => cliclack::log::remark(tr!( + format!("출력 언어를 {} 에 저장했습니다", path.display()), + format!("saved the output language to {}", path.display()) + ))?, + Err(e) => cliclack::log::warning(tr!( + format!("언어 설정을 저장하지 못함: {e}"), + format!("could not save the language preference: {e}") + ))?, + } + } + Ok(locale) +} + +fn ask_plan(opts: &SetupOptions) -> Result { + let locale = airlock_i18n::locale(); + let mut items: Vec<(&str, &str, &str)> = PRESETS + .iter() + .map(|p| (p.id, p.label, p.hint(locale))) + .collect(); + items.push(( + "custom", + tr!("직접 설정", "Custom"), + tr!( + "프리셋 없이 질문으로 처음부터 구성", + "build from scratch through questions, without a preset" + ), + )); + let id = cliclack::select(tr!( + "어떤 방식으로 시작할까요?", + "how would you like to start?" + )) + .items(&items) + .interact()?; if id == "custom" { return Ok(Plan::Custom(ask_custom(opts)?)); } @@ -149,53 +229,111 @@ fn ask_plan(opts: &SetupOptions) -> Result { fn ask_custom(opts: &SetupOptions) -> Result { let default_name = default_policy_name(&opts.cwd); - let name: String = cliclack::input("정책 이름은?") + let name: String = cliclack::input(tr!("정책 이름은?", "policy name?")) .default_input(&default_name) .validate(|s: &String| { if s.trim().is_empty() { - Err("이름을 입력하세요") + Err(tr!("이름을 입력하세요", "enter a name")) } else { Ok(()) } }) .interact()?; - let file_default = cliclack::select("파일 기본 동작은?") + let file_default = cliclack::select(tr!("파일 기본 동작은?", "default action for files?")) .items(&[ - ("deny", "deny", "목록 밖 경로는 차단 (권장)"), - ("ask", "ask", "목록 밖 경로는 승인 요청"), - ("allow", "allow", "베이스라인 보호만 남기고 개방"), - ]) - .interact()?; - let exec_default = cliclack::select("실행 기본 동작은?") - .items(&[ - ("ask", "ask", "모르는 명령은 승인 요청 (권장)"), + ( + "deny", + "deny", + tr!( + "목록 밖 경로는 차단 (권장)", + "block unlisted paths (recommended)" + ), + ), + ( + "ask", + "ask", + tr!( + "목록 밖 경로는 승인 요청", + "ask approval for unlisted paths" + ), + ), ( "allow", "allow", - "실행 허용. 위험 명령 ask는 베이스라인이 유지", + tr!( + "베이스라인 보호만 남기고 개방", + "open up, keeping only baseline protections" + ), ), - ("deny", "deny", "목록 밖 명령은 차단"), ]) .interact()?; - let egress_default = cliclack::select("아웃바운드 기본 동작은?") + let exec_default = cliclack::select(tr!("실행 기본 동작은?", "default action for exec?")) .items(&[ - ("deny", "deny", "허용 목록 밖 차단 (권장)"), - ("ask", "ask", "허용 목록 밖 승인 요청"), + ( + "ask", + "ask", + tr!( + "모르는 명령은 승인 요청 (권장)", + "ask approval for unknown commands (recommended)" + ), + ), + ( + "allow", + "allow", + tr!( + "실행 허용. 위험 명령 ask는 베이스라인이 유지", + "allow exec; the baseline still asks on dangerous commands" + ), + ), + ( + "deny", + "deny", + tr!("목록 밖 명령은 차단", "block unlisted commands"), + ), ]) .interact()?; + let egress_default = cliclack::select(tr!( + "아웃바운드 기본 동작은?", + "default action for outbound connections?" + )) + .items(&[ + ( + "deny", + "deny", + tr!( + "허용 목록 밖 차단 (권장)", + "block outside the allowlist (recommended)" + ), + ), + ( + "ask", + "ask", + tr!( + "허용 목록 밖 승인 요청", + "ask approval outside the allowlist" + ), + ), + ]) + .interact()?; let workspace = ask_workspace(opts)?; let (toolchain_read, build_cache) = if file_default == "allow" { (false, false) } else { - let toolchain = cliclack::confirm("시스템 툴체인 읽기·실행을 허용할까요? (/usr, /bin 등)") - .initial_value(true) - .interact()?; - let cache = cliclack::confirm("빌드 캐시 접근을 허용할까요? (~/.cargo, ~/.rustup, ~/.npm)") - .initial_value(true) - .interact()?; + let toolchain = cliclack::confirm(tr!( + "시스템 툴체인 읽기·실행을 허용할까요? (/usr, /bin 등)", + "allow reading and executing the system toolchain? (/usr, /bin, ...)" + )) + .initial_value(true) + .interact()?; + let cache = cliclack::confirm(tr!( + "빌드 캐시 접근을 허용할까요? (~/.cargo, ~/.rustup, ~/.npm)", + "allow access to build caches? (~/.cargo, ~/.rustup, ~/.npm)" + )) + .initial_value(true) + .interact()?; (toolchain, cache) }; @@ -217,12 +355,21 @@ fn ask_egress_hosts() -> Result, SetupError> { let mut hosts: Vec<(String, u16)> = Vec::new(); loop { let prompt = if hosts.is_empty() { - "허용할 아웃바운드 호스트는? (비우면 건너뜀)" + tr!( + "허용할 아웃바운드 호스트는? (비우면 건너뜀)", + "outbound hosts to allow? (leave empty to skip)" + ) } else { - "호스트를 더 추가할까요? (비우면 완료)" + tr!( + "호스트를 더 추가할까요? (비우면 완료)", + "add another host? (leave empty to finish)" + ) }; let raw: String = cliclack::input(prompt) - .placeholder("api.example.com 또는 api.example.com:8443") + .placeholder(tr!( + "api.example.com 또는 api.example.com:8443", + "api.example.com or api.example.com:8443" + )) .required(false) .validate(|s: &String| match parse_host(s) { Ok(_) => Ok(()), @@ -232,7 +379,10 @@ fn ask_egress_hosts() -> Result, SetupError> { match parse_host(&raw) { Ok(Some(entry)) => { if hosts.contains(&entry) { - cliclack::log::info(format!("{} 은(는) 이미 추가됨", entry.0))?; + cliclack::log::info(tr!( + format!("{} 은(는) 이미 추가됨", entry.0), + format!("{} is already added", entry.0) + ))?; } else { hosts.push(entry); } @@ -250,17 +400,22 @@ fn parse_host(raw: &str) -> Result, &'static str> { return Ok(None); } if t.chars().any(char::is_whitespace) { - return Err("공백 없이 입력하세요"); + return Err(tr!("공백 없이 입력하세요", "enter it without whitespace")); } let (host, port) = match t.rsplit_once(':') { Some((h, p)) => { - let port = p.parse::().map_err(|_| "포트는 1-65535 숫자입니다")?; + let port = p.parse::().map_err(|_| { + tr!( + "포트는 1-65535 숫자입니다", + "the port must be a number between 1 and 65535" + ) + })?; (h, port) } None => (t, 443), }; if host.is_empty() { - return Err("호스트가 비어 있습니다"); + return Err(tr!("호스트가 비어 있습니다", "the host is empty")); } Ok(Some((host.to_ascii_lowercase(), port))) } @@ -290,33 +445,41 @@ fn default_policy_name(cwd: &Path) -> String { fn ask_workspace(opts: &SetupOptions) -> Result { let default = opts.cwd.display().to_string(); - let workspace: String = cliclack::input("쓰기를 허용할 작업 공간 경로는?") - .default_input(&default) - .validate(|s: &String| { - let t = s.trim(); - if t.is_empty() { - Err("경로를 입력하세요") - } else if t == "~" || t.starts_with('/') || t.starts_with("~/") { - Ok(()) - } else { - Err("절대 경로 또는 ~로 시작하는 경로만 받습니다") - } - }) - .interact()?; + let workspace: String = cliclack::input(tr!( + "쓰기를 허용할 작업 공간 경로는?", + "workspace path to allow writing in?" + )) + .default_input(&default) + .validate(|s: &String| { + let t = s.trim(); + if t.is_empty() { + Err(tr!("경로를 입력하세요", "enter a path")) + } else if t == "~" || t.starts_with('/') || t.starts_with("~/") { + Ok(()) + } else { + Err(tr!( + "절대 경로 또는 ~로 시작하는 경로만 받습니다", + "only absolute paths or paths starting with ~ are accepted" + )) + } + }) + .interact()?; let workspace = workspace.trim().to_string(); if workspace == "~" || Path::new(&workspace) == opts.home { - cliclack::log::warning( + cliclack::log::warning(tr!( "홈 전체를 작업 공간으로 열면 격리가 무의미해집니다. 프로젝트 디렉토리를 권장합니다", - )?; + "opening your entire home as the workspace defeats isolation; a project \ + directory is recommended" + ))?; } Ok(workspace) } fn confirm_overwrite(out: &Path) -> Result { - let keep = cliclack::confirm(format!( - "{} 파일이 이미 있습니다. 덮어쓸까요?", - out.display() + let keep = cliclack::confirm(tr!( + format!("{} 파일이 이미 있습니다. 덮어쓸까요?", out.display()), + format!("{} already exists. Overwrite it?", out.display()) )) .initial_value(false) .interact()?; diff --git a/examples/policy/en/claude-code.toml b/examples/policy/en/claude-code.toml new file mode 100644 index 0000000..3508e43 --- /dev/null +++ b/examples/policy/en/claude-code.toml @@ -0,0 +1,221 @@ +# A policy for running Claude Code under airlock. +# +# airlock run --policy examples/policy/claude-code.toml --workspace ~/work/myproj -- claude +# +# The built-in baseline has no egress allow rules at all. So if you run +# airlock run -- claude without a policy file, outbound is shut down entirely, and +# Claude Code cannot even find api.anthropic.com over DNS and dies with +# ENOTFOUND. This file writes down the minimal set that must be opened then. +# +# Watch out! macOS Seatbelt cannot express host-level egress. If even one egress +# allow exists below, the kernel profile opens outbound entirely. That is, this +# host list is a declaration of intent at the policy layer, not an enforced boundary +# on macOS. Linux Landlock also enforces only down to the port. Real host enforcement +# only appears once the egress proxy layer lands (docs/design.md 10.1). airlock +# exposes this fact as-is in the banner's limitations list. + +version = 1 +name = "claude-code" + +[defaults] +file = "deny" +exec = "allow" +egress = "deny" # allow is forbidden at the grammar level +# Same as the default, but stated explicitly. A preset is a document that shows the spec to humans. +# The egress allow rules below are all 443 and state no protocol. A protocol condition only +# gains meaning with an egress proxy layer attached; in a session without one the rule is dead entirely. +egress_plaintext = "deny" + +# Even though the exec default is allow, the baseline's risky exec ask rules stay +# alive. Because this opens via [defaults] (tier 4), not a user rule (tier 2), +# tier 3 ask rules such as sudo, rm -rf, and curl pipe shell match first. + +# +# Workspace +# +# Replace this path with your actual project and pass the same path to --workspace. +# The --workspace flag opens the kernel profile's write window; the rule below is the policy layer's verdict. +[[rules]] +id = "workspace" +kind = "file" +path = "~/work/**" +action = "allow" + +# +# Toolchain read +# +# The compilers and CLIs the agent invokes live here. Writing is not opened. +# /etc/** is deliberately left out. It would put a user rule in the position of +# touching /etc/shadow and /etc/sudoers, which the baseline catches as forbid. +[[rules]] +id = "toolchain-read" +kind = "file" +path = ["/usr/**", "/bin/**", "/sbin/**", "/opt/homebrew/**", "/System/**", "/Library/**"] +mode = ["read", "metadata", "exec"] +action = "allow" + +# +# Claude Code installation +# +# This assumes a native install. For a global npm install, enter that path instead. +[[rules]] +id = "claude-install" +kind = "file" +path = ["~/.local/share/claude/**", "~/.local/bin/**"] +mode = ["read", "metadata", "exec"] +action = "allow" + +# +# Claude Code state +# +# Sessions, history, settings, and shell snapshots all live here, so writing is needed. +# ~/.claude.json holds the MCP server list and project state. +[[rules]] +id = "claude-state" +kind = "file" +path = ["~/.claude/**", "~/.claude.json*"] +action = "allow" + +# +# Build caches +# +# Without these, cargo and npm fail every time. +[[rules]] +id = "build-cache" +kind = "file" +path = ["~/.cargo/registry/**", "~/.cargo/git/**", "~/.rustup/**", "~/.npm/_cacache/**"] +action = "allow" + +# +# Temporary files +# +# On macOS, /tmp is a symbolic link to /private/tmp. The policy evaluates both +# the requested path and the resolved path (docs/policy-dsl.md 4.1), so both are listed. +[[rules]] +id = "temp-files" +kind = "file" +path = ["/tmp/**", "/private/tmp/**", "/private/var/folders/**"] +action = "allow" + +# +# Login credentials +# +# Claude Code puts its OAuth token in the "Claude Code-credentials" entry of the +# macOS keychain. Without this rule the process starts but stalls at 'Not logged in'. +# +# Warning! A path-based policy cannot express "only that one entry". That is, this +# rule lets the agent read the entire login keychain. securityd's ACLs judge each +# entry's actual access again, but the boundary airlock provides stops at the file level. +# +# The data protection keychain (~/Library/Keychains//) is deliberately left +# out. iCloud and Safari credentials live there and Claude Code does not use it. +# +# If you pass ANTHROPIC_API_KEY as an environment variable, there is no need to open +# the keychain at all. For an agent running inside a sandbox, that is the better choice. +[[rules]] +id = "keychain-login-ro" +kind = "file" +path = "~/Library/Keychains/login.keychain-db" +mode = ["read", "metadata"] +action = "allow" + +# +# Anthropic endpoints +# +# Up to here is the minimum Claude Code needs to start, log in, and converse. +[[rules]] +id = "anthropic-api" +kind = "egress" +host = "api.anthropic.com" +port = 443 +action = "allow" + +[[rules]] +id = "claude-code-service" +kind = "egress" +host = "code.claude.com" +port = 443 +action = "allow" + +[[rules]] +id = "claude-platform" +kind = "egress" +host = "platform.claude.com" +port = 443 +action = "allow" + +# OAuth login flow +[[rules]] +id = "claude-login" +kind = "egress" +host = "claude.ai" +port = 443 +action = "allow" + +[[rules]] +id = "anthropic-console-login" +kind = "egress" +host = "console.anthropic.com" +port = 443 +action = "allow" + +# Auto updates. +# If autoUpdates is turned off, this is not really needed. +[[rules]] +id = "claude-downloads" +kind = "egress" +host = "downloads.claude.ai" +port = 443 +action = "allow" + +# +# Development tool endpoints +# +# Needed only when you have the agent run git and install packages. Delete these if unused. +# *.githubusercontent.com matches only subdomains, never the apex +# (docs/policy-dsl.md section 8). raw and objects are what get filtered here. +[[rules]] +id = "github-web" +kind = "egress" +host = "github.com" +port = 443 +action = "allow" + +[[rules]] +id = "github-api" +kind = "egress" +host = "api.github.com" +port = 443 +action = "allow" + +[[rules]] +id = "github-content" +kind = "egress" +host = "*.githubusercontent.com" +port = 443 +action = "allow" + +[[rules]] +id = "npm-registry" +kind = "egress" +host = "registry.npmjs.org" +port = 443 +action = "allow" + +# +# Disabled entries +# +# ~/.config/gh/hosts.yml holds a GitHub token in plaintext. Allowing reads makes the +# gh CLI work, but that token immediately becomes an exfiltratable credential. If you +# need it, use the rules below, but enable them only after understanding exactly what they mean. +# +# [[rules]] +# id = "gh-token" +# kind = "file" +# path = "~/.config/gh/**" +# mode = ["read", "metadata"] +# action = "allow" +# +# ~/.ssh and ~/.aws are baseline forbid, so they cannot be opened here. If you +# really need them, you must name those rule ids in overrides and write a reason +# (docs/policy-dsl.md 3.2). For git push, HTTPS is a better choice than SSH. diff --git a/examples/policy/en/developer.toml b/examples/policy/en/developer.toml new file mode 100644 index 0000000..d88cb64 --- /dev/null +++ b/examples/policy/en/developer.toml @@ -0,0 +1,49 @@ +# The developer convenience form. Only secrets are blocked and the rest passes through. +# The built-in baseline keeps catching secrets and persistence paths. +# +# defaults.file = "allow" below is the policy layer's verdict. The kernel enforcement +# profile defaults to deny, so outside the workspace and system paths nothing opens unless +# a rule states it. That is, ordinary files outside the workspace are blocked under +# airlock run and are read as the policy decides only under --observe. The kernel being +# narrower than the policy is safe, but paths you actually need to read must be written as allow rules. + +version = 1 +name = "developer" + +[defaults] +file = "allow" +exec = "allow" +egress = "deny" +# Same as the default, but stated explicitly. A preset is a document that shows the spec to humans. +# Even with file set to allow, plaintext outbound cannot cross this floor. +egress_plaintext = "deny" + +# System paths are dropped to read-only +[[rules]] +id = "system-readonly" +kind = "file" +path = ["/usr/**", "/System/**", "/bin/**", "/sbin/**", "/Library/**"] +mode = ["read", "metadata", "exec"] +action = "allow" + +[[rules]] +id = "system-write-guard" +kind = "file" +path = ["/usr/**", "/System/**", "/bin/**", "/sbin/**", "/Library/**"] +mode = ["write", "create", "delete"] +action = "ask" + +# Force push is hard to undo +[[rules]] +id = "git-force-push" +kind = "exec" +program = "git" +argv_contains = ["push", "--force"] +action = "ask" + +[[rules]] +id = "llm-api" +kind = "egress" +host = "api.foollm.com" +port = 443 +action = "allow" diff --git a/examples/policy/en/strict.toml b/examples/policy/en/strict.toml new file mode 100644 index 0000000..55d2108 --- /dev/null +++ b/examples/policy/en/strict.toml @@ -0,0 +1,364 @@ +# The zero trust baseline form. Block everything outside the workspace and pass only what is needed. +# Validate with airlock policy check --policy examples/policy/strict.toml. +# +# Since [defaults].exec is not allow, exec becomes a kernel whitelist. Only the programs +# and top-level commands written in the kind = "exec" allow rules below run; the kernel +# refuses everything else. If a program you need is missing, add that line. The point of +# this preset is that widening is easy, but the fact that you widened stays in the policy file. + +version = 1 +name = "strict" + +[defaults] +file = "deny" +exec = "ask" +egress = "deny" +# Same as the default, but stated explicitly. A preset is a document that shows the spec to humans. +# Plaintext outbound cannot cross this floor even toward allowed hosts. +egress_plaintext = "deny" + +# Workspace +[[rules]] +id = "workspace" +kind = "file" +path = "~/work/**" +action = "allow" + +# Toolchain read: needed to read executables and libraries. +# +# It matters that mode carries no exec. Granting exec here would widen the +# whitelist to all of /usr, /bin, /System, and /Library, making virtually the +# whole system executable. Execution is opened only by the kind = "exec" rules below. +[[rules]] +id = "toolchain-read" +kind = "file" +path = ["/usr/**", "/bin/**", "/opt/homebrew/**", "/System/**", "/Library/**"] +mode = ["read", "metadata"] +action = "allow" + +# Build caches: writing is needed +[[rules]] +id = "build-cache" +kind = "file" +path = ["~/.cargo/registry/**", "~/.cargo/git/**", "~/.rustup/**", "~/.npm/_cacache/**"] +action = "allow" + +# +# Exec allow list +# +# Writing only a name in program opens just the path it resolves to on the current PATH. +# Writing a file with the same name into the workspace does not make it run. + +# Shells +# Without a shell the agent cannot run a single command. macOS /bin/sh re-execs +# itself as /bin/bash through the dyld variant mechanism, so both are needed +# (docs/limitations.md 4.13). +[[rules]] +id = "exec-sh" +kind = "exec" +program = "sh" +action = "allow" + +[[rules]] +id = "exec-bash" +kind = "exec" +program = "bash" +action = "allow" + +[[rules]] +id = "exec-zsh" +kind = "exec" +program = "zsh" +action = "allow" + +# Exec helpers +# env and xargs are conduits that exec other programs on one's behalf. Once opened, +# whatever launches through them is judged again by this list. which is for path lookup. +[[rules]] +id = "exec-env" +kind = "exec" +program = "env" +action = "allow" + +[[rules]] +id = "exec-xargs" +kind = "exec" +program = "xargs" +action = "allow" + +[[rules]] +id = "exec-which" +kind = "exec" +program = "which" +action = "allow" + +# File manipulation +# The minimal set the agent uses to read and modify the workspace. +[[rules]] +id = "exec-ls" +kind = "exec" +program = "ls" +action = "allow" + +[[rules]] +id = "exec-cat" +kind = "exec" +program = "cat" +action = "allow" + +[[rules]] +id = "exec-cp" +kind = "exec" +program = "cp" +action = "allow" + +[[rules]] +id = "exec-mv" +kind = "exec" +program = "mv" +action = "allow" + +[[rules]] +id = "exec-rm" +kind = "exec" +program = "rm" +action = "allow" + +[[rules]] +id = "exec-mkdir" +kind = "exec" +program = "mkdir" +action = "allow" + +[[rules]] +id = "exec-ln" +kind = "exec" +program = "ln" +action = "allow" + +[[rules]] +id = "exec-chmod" +kind = "exec" +program = "chmod" +action = "allow" + +[[rules]] +id = "exec-touch" +kind = "exec" +program = "touch" +action = "allow" + +[[rules]] +id = "exec-stat" +kind = "exec" +program = "stat" +action = "allow" + +[[rules]] +id = "exec-find" +kind = "exec" +program = "find" +action = "allow" + +# Text processing +# Used for code search and editing. +[[rules]] +id = "exec-grep" +kind = "exec" +program = "grep" +action = "allow" + +[[rules]] +id = "exec-sed" +kind = "exec" +program = "sed" +action = "allow" + +[[rules]] +id = "exec-awk" +kind = "exec" +program = "awk" +action = "allow" + +[[rules]] +id = "exec-head" +kind = "exec" +program = "head" +action = "allow" + +[[rules]] +id = "exec-tail" +kind = "exec" +program = "tail" +action = "allow" + +[[rules]] +id = "exec-sort" +kind = "exec" +program = "sort" +action = "allow" + +[[rules]] +id = "exec-uniq" +kind = "exec" +program = "uniq" +action = "allow" + +[[rules]] +id = "exec-wc" +kind = "exec" +program = "wc" +action = "allow" + +[[rules]] +id = "exec-cut" +kind = "exec" +program = "cut" +action = "allow" + +[[rules]] +id = "exec-tr" +kind = "exec" +program = "tr" +action = "allow" + +[[rules]] +id = "exec-diff" +kind = "exec" +program = "diff" +action = "allow" + +[[rules]] +id = "exec-tee" +kind = "exec" +program = "tee" +action = "allow" + +[[rules]] +id = "exec-basename" +kind = "exec" +program = "basename" +action = "allow" + +[[rules]] +id = "exec-dirname" +kind = "exec" +program = "dirname" +action = "allow" + +# Miscellaneous +# Things scripts commonly invoke. +[[rules]] +id = "exec-echo" +kind = "exec" +program = "echo" +action = "allow" + +[[rules]] +id = "exec-date" +kind = "exec" +program = "date" +action = "allow" + +[[rules]] +id = "exec-sleep" +kind = "exec" +program = "sleep" +action = "allow" + +[[rules]] +id = "exec-uname" +kind = "exec" +program = "uname" +action = "allow" + +# Toolchain +# Delete the lines for languages you do not use. A name absent from PATH shows +# up in the banner as a limitation, and the kernel refuses to execute that program. +[[rules]] +id = "exec-git" +kind = "exec" +program = "git" +action = "allow" + +[[rules]] +id = "exec-cargo" +kind = "exec" +program = "cargo" +action = "allow" + +[[rules]] +id = "exec-rustc" +kind = "exec" +program = "rustc" +action = "allow" + +[[rules]] +id = "exec-make" +kind = "exec" +program = "make" +action = "allow" + +[[rules]] +id = "exec-cc" +kind = "exec" +program = "cc" +action = "allow" + +[[rules]] +id = "exec-python3" +kind = "exec" +program = "python3" +action = "allow" + +[[rules]] +id = "exec-node" +kind = "exec" +program = "node" +action = "allow" + +[[rules]] +id = "exec-npm" +kind = "exec" +program = "npm" +action = "allow" + +# macOS developer tool shims +# +# On macOS, /usr/bin/git, /usr/bin/make, /usr/bin/cc, and /usr/bin/python3 are not the +# real programs but shims that re-exec into /Library/Developer/CommandLineTools. With the +# name rules above alone, that re-exec is blocked in the kernel and the program never +# starts at all (the same nature as the /bin/sh dyld variant, docs/limitations.md 4.13). +# +# Opening only usr/bin is not enough. Entries inside it, like python3, are symbolic links +# pointing at the real files on the framework side, and the kernel judges by the resolved +# path. So we open the whole CommandLineTools tree in one piece. +# +# This rule also opens the other executables inside the developer tool tree. Even so, it +# is far narrower than opening all of /usr, /System, and /Library, and tools used for +# exfiltration such as curl, nc, ssh, and osascript remain outside. If you use Xcode.app, +# you must add /Applications/Xcode.app/Contents/Developer/** the same way. Linux has no +# such path, so this opens nothing there. +[[rules]] +id = "exec-macos-developer-tools" +kind = "exec" +program = "/Library/Developer/CommandLineTools/**" +action = "allow" + +# +# Outbound +# +# Only actually enforced per host once the egress proxy layer is attached. +[[rules]] +id = "llm-api" +kind = "egress" +host = "api.foollm.com" +port = 443 +action = "allow" + +[[rules]] +id = "crates-io" +kind = "egress" +host = "*.crates.io" +port = 443 +action = "allow" diff --git a/scripts/check.sh b/scripts/check.sh index 22e4f1c..5927f77 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -30,7 +30,7 @@ fi echo "없음" echo "== 정책 프리셋 검증 ==" -for p in examples/policy/*.toml; do +for p in examples/policy/*.toml examples/policy/en/*.toml; do echo "-- $p" cargo run -q -p airlock -- policy check --policy "$p" done @@ -41,9 +41,10 @@ echo "== 배포 메타데이터 ==" # 매니페스트 자체를 검사합니다 cargo metadata --no-deps --format-version 1 | python3 scripts/metadata-check.py -# 의존이 없는 리프는 실제 패키징까지 검사할 수 있습니다 -cargo package -p airlock-canonical --allow-dirty -q >/dev/null -echo "airlock-canonical 패키징 통과" +# 의존이 없는 리프는 실제 패키징까지 검사할 수 있습니다. 워크스페이스 내부 의존이 +# 하나라도 있으면 그 크레이트가 crates.io 에 오르기 전까지 색인을 찾지 못합니다 +cargo package -p airlock-i18n --allow-dirty -q >/dev/null +echo "airlock-i18n 패키징 통과" echo echo "전부 통과" From 3b43fff718bf6fc5b755f3441da41215b54065a8 Mon Sep 17 00:00:00 2001 From: Quant-TheodoreFelix <53819958+Quant-TheodoreFelix@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:49:07 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=EB=AC=B8=EC=84=9C=EC=97=90=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20=EB=A1=9C=EC=BC=80=EC=9D=BC=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `i18n.md` 를 새로 두어 결정 순서와 무엇이 로케일을 따르는지 규격화 - `design.md` 확정 결정과 `limitations.md` 에 리포트 다이제스트와 로케일 결합을 밝힘 - `audit-format.md` 에 `locale` 필드와 report 와 ack 의 로케일 일치 요구를 적음 - README 와 `CHANGELOG.md` 와 `setup-wizard.md` 갱신 Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 3 ++ README.md | 2 ++ README_KR.md | 2 ++ docs/README.md | 1 + docs/audit-format.md | 4 +++ docs/design.md | 6 ++++ docs/i18n.md | 69 ++++++++++++++++++++++++++++++++++++++++++++ docs/limitations.md | 9 ++++++ docs/setup-wizard.md | 15 ++++++---- 9 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 docs/i18n.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca356a..59c9f78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ ### 추가 +- **출력 로케일 (한국어·영문).** 모든 사람용 출력(CLI 도움말, 배너와 한계 목록, 승인 프롬프트, 감사 보고, 마법사, 생성되는 정책 파일의 주석)이 로케일을 따름. 결정 순서는 `AIRLOCK_LANG` -> `~/.config/airlock/config.toml`의 `locale` 키 -> `LC_ALL`/`LC_MESSAGES`/`LANG` 접두 -> 기본 한국어. 기계 판독 필드(JSON 키, `kind`·`status`·`verdict`, 스키마 이름, 규칙 id)와 **정책 다이제스트는 로케일과 무관하게 고정**임. 자세한 것은 `docs/i18n.md` +- **`airlock setup` 첫 질문이 언어 선택.** 답하는 즉시 나머지 질문이 그 언어로 나가고 선택이 구성 파일에 저장됨. 영문 프리셋(`examples/policy/en/*.toml`)이 추가되었으며 한국어판과 주석만 다르고 다이제스트가 같음을 테스트가 강제함 +- `airlock audit report --json` 최상위에 `locale` 필드 추가. 이상 `detail` 산문이 로케일을 따르므로 리포트 본문 다이제스트가 생성 로케일에 묶이며, `report`와 `ack`는 같은 로케일에서 돌려야 함 (`docs/limitations.md` 7.18) - **`airlock audit report`. 매일 이상여부 점검.** 범위 안의 모든 세션을 검증하고 결정·승인·거부된 exec·목적지별 아웃바운드를 집계해 한 번에 보고함. `--since`/`--until`은 그 날을 통째로 덮고, `--json`은 색상 코드 없는 안정된 스키마(`airlock.audit-report.v1`)로 나감. **이상이 하나라도 있으면 종료 코드가 비영**이므로 cron 이나 launchd 에 그대로 걸 수 있음. 종료 코드는 0 이상 없음, 2 증거 이상, 3 운영 이상, 64 인자 오류, 70 내부 오류임 - **판단 불가를 통과로 보고하지 않음.** 앵커 없음, 세션을 읽지 못함, 확인 체인 손상, **앵커에는 있는데 디렉토리가 없는 세션**을 전부 증거 이상으로 셈. 마지막 항목이 세션 통째 삭제를 잡으며, 남은 세션만 훑는 보고로는 그것이 "이상 없음" 으로 나옴 - **승인 집계가 사람 신원 있는 응답과 없는 응답을 나눔.** `--yes` 자동 승인은 `approver_uid`·`approver_tty`가 둘 다 없으므로 여기서 갈리며, 사람 확인처럼 보이지 않음. `--strict-approval`은 신원 없이 **허용된** 건만 이상으로 셈. 신원 없는 거부는 아무것도 통과시키지 않았으므로 세지 않음 diff --git a/README.md b/README.md index 7c41fc4..ba275a5 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ $ cp examples/policy/strict.toml airlock.toml The semantics of the policy system are written up in [policy-dsl.md](docs/policy-dsl.md). +All human-facing output is available in Korean (default) and English. `airlock setup` asks for the language first and saves the choice to `~/.config/airlock/config.toml`; `AIRLOCK_LANG=en` overrides it per run. English preset variants live in `examples/policy/en/`. Details in [i18n.md](docs/i18n.md). + Enforcing host level egress policy for real requires `--egress-proxy`. Without this flag, the host list in a policy is only a declaration of intent. ```bash diff --git a/README_KR.md b/README_KR.md index 0960cde..a8c54f7 100644 --- a/README_KR.md +++ b/README_KR.md @@ -44,6 +44,8 @@ $ cp examples/policy/strict.toml airlock.toml 정책 시스템에 관한 의미론적 문서를 [policy-dsl.md](docs/policy-dsl.md)에 정리해 두었습니다. +모든 사람용 출력은 한국어(기본)와 영문을 지원합니다. `airlock setup`의 첫 질문이 언어 선택이며 선택은 `~/.config/airlock/config.toml`에 저장됩니다. 일회성 전환은 `AIRLOCK_LANG=en`으로 합니다. 영문 프리셋은 `examples/policy/en/`에 있습니다. 자세한 내용은 [i18n.md](docs/i18n.md)를 참고하세요. + 호스트 단위 egress 정책을 실제로 강제하려면 `--egress-proxy`가 필요합니다. 이 플래그가 없으면 정책의 호스트 목록은 의도 선언에 그칩니다. ```bash diff --git a/docs/README.md b/docs/README.md index 83681fa..01b0eff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ ## 가이드 - [setup-wizard.md](setup-wizard.md) 대화형 설정 마법사 `airlock setup`의 구조와 흐름 +- [i18n.md](i18n.md) 출력 로케일 (한국어·영문), 결정 순서와 다이제스트 결합 ## 규격 diff --git a/docs/audit-format.md b/docs/audit-format.md index ca63deb..e017c65 100644 --- a/docs/audit-format.md +++ b/docs/audit-format.md @@ -399,6 +399,8 @@ airlock audit report [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--json] 사람용 출력은 세션마다 목록에 상한을 두지만 **상한이 걸린 사실 자체를 반드시 함께 찍습니다.** 조용한 절단은 "전부 점검했다" 로 읽힙니다. `--json` 출력은 절단하지 않으며 색상 코드도 섞지 않습니다. JSON 스키마 이름은 `airlock.audit-report.v1` 입니다. +JSON 최상위에 `locale` 필드가 있습니다. 이상 `detail` 같은 산문 필드는 출력 로케일(`docs/i18n.md`)을 따르므로 어느 로케일로 생성된 보고인지를 남깁니다. `kind` · `status` · `verdict` 와 JSON 키는 로케일과 무관하게 고정이며, 기계 분류는 그 필드로 해야 합니다. + ### 9.3 `airlock audit ack` 책임자가 점검했다는 사실을 append-only 체인에 남깁니다. @@ -465,6 +467,8 @@ report_digest = SHA-256( `body` 는 리포트가 담은 사실들의 다이제스트이며 생성 시각은 들어가지 않습니다. 시각이 들어가면 같은 사실을 두 번 계산할 때마다 값이 달라져 확인 도장이 어떤 리포트도 가리키지 못합니다. +`body` 에는 이상 `detail` 같은 산문이 들어가고 산문은 출력 로케일을 따릅니다. 곧 **`report` 와 `ack` 는 같은 로케일에서 돌려야 사람이 본 다이제스트와 도장이 일치합니다** (`docs/limitations.md` 7.18). + 범위를 다이제스트 안에 묶는 이유는 하나입니다. **사람이 보지 않은 범위에 확인 도장을 옮겨 찍을 수 없어야 합니다.** `airlock audit ack` 는 리포트를 다시 계산해 그 범위와 다이제스트를 기록하며, 호출자가 다이제스트를 직접 넘기는 경로를 두지 않습니다. 어떤 확인 줄이 지금 계산한 리포트를 실제로 가리키는지는 범위와 다이제스트를 함께 대조해 판정합니다. 범위가 다르면 `ScopeMismatch`, 본문이 다르면 `DigestMismatch` 입니다. ### 9.5 이 체인이 막지 못하는 것 diff --git a/docs/design.md b/docs/design.md index 98be6c1..1de5cc3 100644 --- a/docs/design.md +++ b/docs/design.md @@ -176,6 +176,12 @@ seccomp user notification 층은 강제 층이 아니라 **관측과 승인 채 승인 프롬프트는 에이전트가 제공한 설명이 아니라 브로커가 관측한 원본 사실(정규화된 절대 경로, argv 배열 원본, 매칭된 규칙과 그 근거)만 표시합니다. 에이전트가 만든 문자열은 승인 화면에 넣지 않습니다. +### 9.5 출력 로케일: 표시 계층만, 정책 밖 구성 파일 + +사람용 출력은 한국어(기본)와 영문을 지원합니다. 로케일은 표시 계층만 바꾸며 어떤 보안 판정도 바꾸지 않습니다. 기계 판독 필드(JSON 키, `kind`·`status`·`verdict`, 스키마 이름, 규칙 id)와 정책 다이제스트는 로케일과 무관하게 고정입니다. + +로케일을 정책 파일이 아니라 별도 구성 파일(`~/.config/airlock/config.toml`)에 두는 이유는 하나입니다. 정책 파일에 두면 표시 언어를 바꿀 때마다 정책 다이제스트가 달라져 감사 로그에 소음이 생깁니다. 결정 순서와 다이제스트 결합의 한계는 [`docs/i18n.md`](./i18n.md)와 [`docs/limitations.md`](./limitations.md) 7.18에 있습니다. + ## 10. 기술 제약 (2026-07 조사 기준) 개발 전 조사 작업에서 확인된 제약이며, 설계를 바꾸는 사실만 남깁니다. diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 0000000..c373126 --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,69 @@ +# 출력 로케일 (i18n) + +airlock의 사람용 출력을 한국어와 영문으로 전환하는 기구다. 기본은 한국어이며, 어떤 환경 신호도 없으면 기존 동작이 그대로 유지된다. + +## 1. 무엇이 로케일을 따르는가 + +로케일은 **표시 계층만** 바꾼다. + +로케일을 따르는 것. + +- CLI 도움말(`--help`), 배너와 한계(limit) 목록, 세션 요약, 경고와 오류 산문 +- `/dev/tty` 승인 프롬프트와 응답 표기 +- `airlock audit verify` · `show` · `report` · `ack` 의 사람용 출력 +- `airlock setup` 마법사의 모든 질문과 산출물(정책 파일)의 주석 +- 감사 보고의 이상 `detail` 산문과 `--json` 안의 산문 필드 + +로케일과 무관하게 고정인 것. + +- 기계 판독 필드 전부. JSON 키, 스키마 이름(`airlock.audit-report.v1` 등), `kind` · `status` · `verdict` 의 `as_str` 값, 규칙 id, 프로토콜 태그, 인코딩 도메인 상수 +- **정책 다이제스트.** 정책의 의미는 표시 언어와 무관하다. 한국어판과 영문판 프리셋은 주석만 다르고 다이제스트가 같으며, 테스트가 이 동일성을 강제한다 +- 커널에 넘어가는 프로파일의 의미 부분. SBPL 주석(`;;`)만 로케일을 따른다 + +감사 로그 안의 산문 필드(승인 note, 거부 사유 등)는 **기록 시점의 로케일**로 남는다. 로그는 append-only이므로 뒤에 로케일을 바꿔도 이미 남은 산문은 바뀌지 않는다. + +## 2. 로케일 결정 순서 + +1. `AIRLOCK_LANG` 환경 변수 (`ko` 또는 `en`) +2. 구성 파일 `~/.config/airlock/config.toml` 의 `locale` 키. `XDG_CONFIG_HOME` 이 절대 경로면 그 아래를 본다 +3. `LC_ALL` -> `LC_MESSAGES` -> `LANG` 의 언어 접두 감지. `en_US.UTF-8` 은 `en`, `ko_KR` 은 `ko` +4. 기본값 한국어. `C`/`POSIX` 를 포함해 모르는 값은 다음 순서로 조용히 내려간다 + +읽기 실패도 조용히 다음 순서로 내려간다. 로케일은 표시 전용이라 실패가 보안 판정을 바꾸지 않기 때문이다. 값을 추측하지는 않는다. 따옴표 없는 값이나 테이블(`[section]`) 안의 `locale` 은 최상위 키가 아니므로 읽지 않고 다음 순서로 내려간다. + +저장은 `locale` 줄 하나만 바꾸거나 넣고 나머지 줄은 한 바이트도 건드리지 않는다. 전체를 파싱해 다시 쓰면 사람이 적어 둔 주석과 배치가 바뀌고, 파서가 이해하지 못하는 파일을 통째로 잃는다. 최상위 `locale` 줄이 없으면 첫 테이블 머리 앞에 넣는다. 끝에 붙이면 그 키가 마지막 테이블 안으로 들어가 최상위 키가 아니게 되기 때문이다. + +```toml +# ~/.config/airlock/config.toml +locale = "en" +``` + +## 3. 왜 정책 파일이 아닌가 + +정책 파일(`airlock.toml`)에 로케일을 두면 표시 언어를 바꿀 때마다 정책 다이제스트가 달라져 감사 로그에 소음이 생긴다. 로케일은 어떤 판정도 바꾸지 않으므로 정책 밖(별도 구성 파일)에 둔다. + +## 4. 설정 경로 + +- `airlock setup` 의 **첫 질문**이 언어 선택이다. 답하는 즉시 나머지 질문이 그 언어로 나가고, 선택이 구성 파일에 저장된다. 저장 실패는 경고로만 남고 마법사는 계속된다 +- 이미 구성 파일이 있으면 그 값이 초기 선택으로 뜬다 +- 일회성 전환은 `AIRLOCK_LANG=en airlock run ...` 처럼 환경 변수로 한다 + +## 5. 프리셋 영문판 + +`examples/policy/en/*.toml` 이 영문 프리셋의 원본이고 `crates/airlock-setup/presets/en/` 에 사본을 둔다. 한국어판과의 관계는 다음 테스트가 강제한다. + +- `presets_stay_in_sync_with_examples` 두 벌(사본과 원본)의 바이트 동일성 +- `en_presets_only_differ_in_comments` 한국어판과 영문판의 **정책 다이제스트 동일성.** 주석 번역이 규칙을 건드리면 실패한다 + +## 6. 리포트 다이제스트와 로케일 + +`airlock audit report` 의 본문 다이제스트는 이상 `detail` 산문을 포함하므로 **생성 로케일에 묶인다.** `report` 로 본 다이제스트와 `ack` 가 찍는 다이제스트가 일치하려면 두 명령을 같은 로케일에서 돌려야 한다. 같은 구성 파일을 읽는 한 자연히 일치하며, `--json` 의 `locale` 필드가 어느 로케일로 생성되었는지를 남긴다. 자세한 것은 `docs/limitations.md` 7.18. + +## 7. 개발 규약 + +- 크레이트 `airlock-i18n` 이 `Locale` 과 전역 상태와 `tr!` 매크로를 제공한다. `main` 이 시작하자마자 `airlock_i18n::init()` 을 부른다 +- **`airlock-i18n` 에는 의존이 하나도 없다.** 이 크레이트는 브로커와 정책 엔진의 의존성 트리에 들어가므로, 여기에 파서나 UI 의존성을 넣으면 `docs/setup-wizard.md` 가 정한 TCB 경계가 넓어진다. 구성 파일에서 읽는 것이 짧은 문자열 하나뿐이라 최소 스캐너로 충분하며, 그래서 TOML 파서를 쓰지 않는다 +- 모든 사용자 대면 문자열은 `tr!("한국어", "english")` 쌍으로 적는다. 한 쪽만 있는 문자열을 남기지 않는다 +- `tr!` 은 포맷하지 않는다. 치환이 필요하면 `tr!(format!("... {x}"), format!("... {x}"))` 로 각 갈래를 `format!` 으로 감싼다. 감싸지 않은 `{}` 자리표시자를 `tr!` 리터럴에 남기면 그대로 출력되는 버그다 +- 한국어 원문은 정본이다. 번역을 고치더라도 한국어 쪽을 함께 검토 없이 바꾸지 않는다. 테스트는 기본 로케일(ko)에서 돌며 한국어 문자열을 그대로 단정한다 +- 단위 테스트에서 전역 로케일을 바꾸지 않는다. 테스트는 병렬로 돌므로 전역을 바꾸면 다른 테스트가 오염된다. 영문 경로는 `Locale` 을 인자로 받는 함수로 검증한다 diff --git a/docs/limitations.md b/docs/limitations.md index 34be17f..f616331 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -700,6 +700,15 @@ SIGKILL로 죽어 `session_end`가 없는 체인이 경고 없이 깨끗하게 - **사람용 출력은 목록에 상한이 있습니다.** 상한이 걸린 사실 자체는 함께 찍지만, 전부 보려면 `--json` 이 필요합니다. - **리포트 본문 다이제스트는 경로에 묶입니다.** 감사 루트나 앵커 루트 경로가 달라지면 같은 사실이라도 다른 다이제스트가 나옵니다. 감사 루트를 옮기면 그 이전의 확인 도장은 재계산으로 대조되지 않습니다. +### 7.18 리포트 다이제스트가 출력 로케일에 묶인다 + +리포트 본문 다이제스트에는 이상 `detail` 과 무결성 실패 사유 같은 **산문이 포함됩니다.** 산문은 출력 로케일(`docs/i18n.md`)을 따르므로, 이상이 하나라도 있는 리포트는 같은 사실이라도 한국어 로케일과 영문 로케일에서 계산한 다이제스트가 다릅니다. 이상이 없는 리포트에는 산문이 없어 다이제스트가 로케일과 무관하게 같습니다. + +- **`report` 와 `ack` 는 같은 로케일에서 돌려야 합니다.** 두 명령이 같은 구성 파일과 환경을 읽는 한 자연히 일치하지만, `AIRLOCK_LANG` 을 한쪽에만 주면 사람이 본 다이제스트와 도장이 찍은 다이제스트가 어긋납니다. 어긋남 자체는 위조가 아니라 "본 것과 찍은 것이 바이트 단위로 같지 않음" 의 정직한 보고입니다. +- 진단을 위해 `--json` 출력에 `locale` 필드가 남습니다. 사람용 출력의 다이제스트가 어느 로케일의 것인지는 그 실행의 로케일 자체가 답입니다. +- 기계 판독 필드(`kind` · `status` · `verdict` · JSON 키)는 로케일과 무관하게 고정이므로, SIEM 파이프라인은 산문이 아니라 그 필드를 분류 기준으로 써야 합니다. +- 감사 로그 안에 이미 기록된 산문 필드(승인 note 등)는 기록 시점의 로케일로 남으며, 뒤에 로케일을 바꿔도 바뀌지 않습니다. 한 로그 안에 두 언어가 섞일 수 있습니다. + --- ## 8. 승인 채널 diff --git a/docs/setup-wizard.md b/docs/setup-wizard.md index 68eec7c..6184f41 100644 --- a/docs/setup-wizard.md +++ b/docs/setup-wizard.md @@ -9,13 +9,16 @@ 대화형 UI 의존성(cliclack, console, toml_edit)은 `airlock-setup`에만 둔다. 강제 층(`airlock-broker`)과 정책 엔진(`airlock-policy`)의 의존성 트리에는 UI 의존성이 절대 들어가지 않는다. TCB 경계를 `cargo tree`로 감사할 수 있게 유지하기 위함이다. +이 경계 때문에 `airlock-i18n`에는 의존이 하나도 없다. 그 크레이트는 브로커와 정책 엔진이 모두 쓰므로 TOML 파서 하나만 들여도 UI 의존성이 TCB 트리로 새어 든다. 구성 파일에서 `locale` 키 하나만 읽고 쓰는 최소 스캐너를 직접 둔 이유다 ([i18n.md](i18n.md)). + ## 흐름 -1. 시작 방식 선택 (claude-code / strict / developer 프리셋 또는 직접 설정) -2. 작업 공간 경로 입력 (기본값 cwd, 절대 경로 또는 `~` 경로만 허용, 홈 전체면 경고) -3. 출력 경로 결정 (기본 `./airlock.toml`, 기존 파일은 명시적 확인 없이 덮어쓰지 않음) -4. 생성 후 `Policy::load_str`로 검증. 검증을 통과하기 전에는 파일을 쓰지 않는다 -5. 요약과 함께 바로 실행할 `airlock run` 명령을 안내 +1. 출력 언어 선택 (한국어 / English). 답하는 즉시 나머지 질문이 그 언어로 나가고 선택이 `~/.config/airlock/config.toml`에 저장된다 ([i18n.md](i18n.md)). 저장 실패는 경고로만 남는다 +2. 시작 방식 선택 (claude-code / strict / developer 프리셋 또는 직접 설정) +3. 작업 공간 경로 입력 (기본값 cwd, 절대 경로 또는 `~` 경로만 허용, 홈 전체면 경고) +4. 출력 경로 결정 (기본 `./airlock.toml`, 기존 파일은 명시적 확인 없이 덮어쓰지 않음) +5. 생성 후 `Policy::load_str`로 검증. 검증을 통과하기 전에는 파일을 쓰지 않는다 +6. 요약과 함께 바로 실행할 `airlock run` 명령을 안내 ### 직접 설정 @@ -32,6 +35,8 @@ `examples/policy/*.toml`이 원본이다. crates.io 패키징이 패키지 루트 밖 파일을 담지 못하므로 `crates/airlock-setup/presets/`에 사본을 두고, 두 벌이 어긋나면 테스트(`presets_stay_in_sync_with_examples`)가 실패한다. +영문 프리셋은 `examples/policy/en/*.toml`이 원본이고 사본은 `presets/en/`이다. 주석만 다르고 정책 의미는 한국어판과 같아야 하며, 다이제스트 동등성 테스트(`en_presets_only_differ_in_comments`)가 이를 강제한다. 마법사는 선택된 로케일의 프리셋에서 출발한다 ([i18n.md](i18n.md)). + 프리셋의 주석은 단순 설명이 아니라 보안 근거(Seatbelt egress 한계 등)를 담고 있다. 그래서 생성은 serde 직렬화가 아니라 toml_edit 기반이다. 프리셋 원문에서 출발해 값만 바꾸므로 주석이 그대로 보존된다. 현재 치환 지점은 `id = "workspace"` 규칙의 `path` 하나다. ## 스타일