1use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6use sha2::{Digest, Sha256};
7
8use crate::errors::{Result, WinxError};
9use crate::tool_policy::{ToolPolicy, ToolProfile};
10
11pub const MIN_HTTP_TOKEN_BYTES: usize = 32;
13
14pub const SERVER_ONLY_ENV: [&str; 4] =
20 ["WINX_USAGE_LOG", "WINX_USAGE_LOG_ROTATION", "WINX_USAGE_LOG_KEEP_DAYS", "WINX_HTTP_TOKEN"];
21
22#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, clap::ValueEnum)]
24pub enum HttpSessionAffinity {
25 #[default]
29 Workspace,
30 Conversation,
35 Thread,
39}
40
41pub fn parse_bool(value: &str) -> Option<bool> {
43 match value.trim().to_ascii_lowercase().as_str() {
44 "1" | "true" | "yes" | "on" => Some(true),
45 "0" | "false" | "no" | "off" | "" => Some(false),
46 _ => None,
47 }
48}
49
50pub fn env_flag(name: &str) -> bool {
52 std::env::var(name).ok().as_deref().and_then(parse_bool).unwrap_or(false)
53}
54
55pub fn env_text(name: &str) -> Option<String> {
57 std::env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty())
58}
59
60pub const DEFAULT_READ_CALL_BUDGET_BYTES: usize = 48 * 1024;
66pub const DEFAULT_READ_FILE_BUDGET_BYTES: usize = 32 * 1024;
70pub const DEFAULT_READ_UNCHANGED_TTL_SECS: u64 = 0;
75pub const MIN_READ_BUDGET_BYTES: usize = 1024;
77
78#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub struct ReadResponseBudget {
81 pub call_bytes: Option<usize>,
83 pub file_bytes: Option<usize>,
86 pub unchanged_ttl: std::time::Duration,
88}
89
90impl Default for ReadResponseBudget {
91 fn default() -> Self {
92 Self::parse(None, None, None)
93 }
94}
95
96impl ReadResponseBudget {
97 pub fn from_env() -> Self {
100 Self::parse(
101 env_text("WINX_READ_CALL_BUDGET_BYTES").as_deref(),
102 env_text("WINX_READ_FILE_BUDGET_BYTES").as_deref(),
103 env_text("WINX_READ_UNCHANGED_TTL_SECS").as_deref(),
104 )
105 }
106
107 pub fn parse(call: Option<&str>, file: Option<&str>, ttl: Option<&str>) -> Self {
111 fn bytes(value: Option<&str>, default: usize) -> Option<usize> {
112 match value.map(str::trim).map(str::parse::<usize>) {
113 Some(Ok(0)) => None,
114 Some(Ok(bytes)) => Some(bytes.max(MIN_READ_BUDGET_BYTES)),
115 Some(Err(_)) | None => Some(default),
116 }
117 }
118 let call_bytes = bytes(call, DEFAULT_READ_CALL_BUDGET_BYTES);
119 let file_bytes = bytes(file, DEFAULT_READ_FILE_BUDGET_BYTES)
120 .map(|file| call_bytes.map_or(file, |call| file.min(call)));
121 let ttl_secs = ttl
122 .and_then(|value| value.trim().parse::<u64>().ok())
123 .unwrap_or(DEFAULT_READ_UNCHANGED_TTL_SECS);
124 Self { call_bytes, file_bytes, unchanged_ttl: std::time::Duration::from_secs(ttl_secs) }
125 }
126}
127
128#[derive(Clone)]
130pub struct HttpPrincipal {
131 name: String,
132 id: String,
133 token_digest: [u8; 32],
134 tool_policy: ToolPolicy,
135 weak_token: bool,
136}
137
138impl std::fmt::Debug for HttpPrincipal {
139 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 formatter
141 .debug_struct("HttpPrincipal")
142 .field("name", &self.name)
143 .field("id", &self.id)
144 .field("tool_policy", &self.tool_policy)
145 .field("token", &"[REDACTED]")
146 .finish_non_exhaustive()
147 }
148}
149
150impl HttpPrincipal {
151 pub fn new(name: impl Into<String>, token: &str, allow_weak_token: bool) -> Result<Self> {
152 Self::new_with_tool_policy(name, token, allow_weak_token, ToolPolicy::default())
153 }
154
155 pub fn new_with_tool_policy(
156 name: impl Into<String>,
157 token: &str,
158 allow_weak_token: bool,
159 tool_policy: ToolPolicy,
160 ) -> Result<Self> {
161 let name = name.into().trim().to_string();
162 if name.is_empty() {
163 return Err(WinxError::ConfigurationError(
164 "HTTP principal name cannot be empty".to_string(),
165 ));
166 }
167 if !name
168 .chars()
169 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
170 {
171 return Err(WinxError::ConfigurationError(format!(
172 "HTTP principal name {name:?} may contain only ASCII letters, digits, '_' and '-'"
173 )));
174 }
175 validate_http_token(token, allow_weak_token)?;
176 let token_digest: [u8; 32] = Sha256::digest(token.as_bytes()).into();
177 let id_digest = Sha256::digest(name.as_bytes());
178 let id = id_digest[..8].iter().fold(String::with_capacity(16), |mut output, byte| {
179 use std::fmt::Write as _;
180 let _ = write!(output, "{byte:02x}");
181 output
182 });
183 let weak_token = token.len() < MIN_HTTP_TOKEN_BYTES;
184 Ok(Self { name, id, token_digest, tool_policy, weak_token })
185 }
186
187 pub fn name(&self) -> &str {
188 &self.name
189 }
190
191 pub fn id(&self) -> &str {
192 &self.id
193 }
194
195 pub fn has_weak_token(&self) -> bool {
199 self.weak_token
200 }
201
202 pub fn session_prefix(&self) -> String {
203 format!("p_{}__", self.id)
204 }
205
206 pub fn task_prefix(&self) -> String {
207 format!("task_{}__", self.id)
208 }
209
210 pub fn matches_token(&self, presented: &str) -> bool {
211 let digest: [u8; 32] = Sha256::digest(presented.as_bytes()).into();
212 constant_time_digest_eq(&self.token_digest, &digest)
213 }
214
215 pub fn tool_policy(&self) -> ToolPolicy {
216 self.tool_policy
217 }
218}
219
220#[derive(Debug, Deserialize)]
221#[serde(deny_unknown_fields)]
222struct PrincipalFile {
223 principals: Vec<PrincipalEntry>,
224}
225
226#[derive(Debug, Deserialize)]
227#[serde(deny_unknown_fields)]
228struct PrincipalEntry {
229 name: String,
230 token_file: Option<PathBuf>,
231 token_env: Option<String>,
232 #[serde(default)]
233 tool_profile: ToolProfile,
234 allowed_tools: Option<Vec<String>>,
235}
236
237pub fn load_http_principals(
239 principal_config: Option<&Path>,
240 command_token: Option<String>,
241 command_token_file: Option<&Path>,
242 allow_weak_token: bool,
243) -> Result<Vec<HttpPrincipal>> {
244 load_http_principals_with_policy(
245 principal_config,
246 command_token,
247 command_token_file,
248 allow_weak_token,
249 ToolPolicy::default(),
250 )
251}
252
253pub fn load_http_principals_with_policy(
256 principal_config: Option<&Path>,
257 command_token: Option<String>,
258 command_token_file: Option<&Path>,
259 allow_weak_token: bool,
260 single_principal_policy: ToolPolicy,
261) -> Result<Vec<HttpPrincipal>> {
262 if principal_config.is_some() && (command_token.is_some() || command_token_file.is_some()) {
263 return Err(WinxError::ConfigurationError(
264 "--principal-config cannot be combined with --token/--token-file".to_string(),
265 ));
266 }
267
268 let principals = if let Some(path) = principal_config {
269 let content = std::fs::read_to_string(path).map_err(|error| {
270 WinxError::ConfigurationError(format!(
271 "cannot read HTTP principal config {}: {error}",
272 path.display()
273 ))
274 })?;
275 let config: PrincipalFile = toml::from_str(&content).map_err(|error| {
276 WinxError::ConfigurationError(format!(
277 "invalid HTTP principal config {}: {error}",
278 path.display()
279 ))
280 })?;
281 if config.principals.is_empty() {
282 return Err(WinxError::ConfigurationError(
283 "HTTP principal config must contain at least one [[principals]] entry".to_string(),
284 ));
285 }
286 config
287 .principals
288 .into_iter()
289 .map(|entry| {
290 let tool_policy =
291 ToolPolicy::resolve(entry.tool_profile, entry.allowed_tools.as_deref())?;
292 let token = match (entry.token_file.as_deref(), entry.token_env.as_deref()) {
293 (Some(path), None) => load_secret_file(path)?,
294 (None, Some(name)) => std::env::var(name).map_err(|error| {
295 WinxError::ConfigurationError(format!(
296 "HTTP principal {} token_env {name:?} is unavailable: {error}",
297 entry.name
298 ))
299 })?,
300 (Some(_), Some(_)) => {
301 return Err(WinxError::ConfigurationError(format!(
302 "HTTP principal {} must set exactly one of token_file or token_env",
303 entry.name
304 )))
305 }
306 (None, None) => {
307 return Err(WinxError::ConfigurationError(format!(
308 "HTTP principal {} must set token_file or token_env",
309 entry.name
310 )))
311 }
312 };
313 HttpPrincipal::new_with_tool_policy(
314 entry.name,
315 token.trim(),
316 allow_weak_token,
317 tool_policy,
318 )
319 })
320 .collect::<Result<Vec<_>>>()?
321 } else {
322 let token = match (command_token, command_token_file) {
323 (Some(_), Some(_)) => {
324 return Err(WinxError::ConfigurationError(
325 "provide only one of --token or --token-file".to_string(),
326 ))
327 }
328 (Some(token), None) => token,
329 (None, Some(path)) => load_secret_file(path)?,
330 (None, None) => env_text("WINX_HTTP_TOKEN").unwrap_or_default(),
331 };
332 vec![HttpPrincipal::new_with_tool_policy(
333 "default",
334 token.trim(),
335 allow_weak_token,
336 single_principal_policy,
337 )?]
338 };
339
340 reject_duplicate_principals(&principals)?;
341 Ok(principals)
342}
343
344pub fn load_secret_file(path: &Path) -> Result<String> {
345 let metadata = std::fs::symlink_metadata(path).map_err(|error| {
346 WinxError::ConfigurationError(format!(
347 "cannot inspect secret file {}: {error}",
348 path.display()
349 ))
350 })?;
351 if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
352 return Err(WinxError::ConfigurationError(format!(
353 "secret path {} must be a regular, non-symlink file",
354 path.display()
355 )));
356 }
357 #[cfg(unix)]
358 {
359 use std::os::unix::fs::PermissionsExt as _;
360 let mode = metadata.permissions().mode();
361 if mode & 0o077 != 0 {
362 return Err(WinxError::ConfigurationError(format!(
363 "secret file {} is readable or writable by group/others; use chmod 600",
364 path.display()
365 )));
366 }
367 }
368 let value = std::fs::read_to_string(path).map_err(|error| {
369 WinxError::ConfigurationError(format!(
370 "cannot read secret file {}: {error}",
371 path.display()
372 ))
373 })?;
374 let value = value.trim().to_string();
375 if value.is_empty() {
376 return Err(WinxError::ConfigurationError(format!(
377 "secret file {} is empty",
378 path.display()
379 )));
380 }
381 Ok(value)
382}
383
384fn validate_http_token(token: &str, allow_weak_token: bool) -> Result<()> {
385 if token.is_empty() {
386 return Err(WinxError::ConfigurationError(
387 "refusing to start HTTP transport without a token (RCE exposure)".to_string(),
388 ));
389 }
390 if !allow_weak_token && token.len() < MIN_HTTP_TOKEN_BYTES {
391 return Err(WinxError::ConfigurationError(format!(
392 "HTTP token is too short ({} bytes); use at least {MIN_HTTP_TOKEN_BYTES} bytes or pass --allow-weak-token for local development",
393 token.len()
394 )));
395 }
396 Ok(())
397}
398
399fn reject_duplicate_principals(principals: &[HttpPrincipal]) -> Result<()> {
400 for (index, principal) in principals.iter().enumerate() {
401 for other in principals.iter().skip(index + 1) {
402 if principal.name == other.name || principal.id == other.id {
403 return Err(WinxError::ConfigurationError(format!(
404 "duplicate HTTP principal name {:?}",
405 principal.name
406 )));
407 }
408 if constant_time_digest_eq(&principal.token_digest, &other.token_digest) {
409 return Err(WinxError::ConfigurationError(format!(
410 "HTTP principals {:?} and {:?} use the same token",
411 principal.name, other.name
412 )));
413 }
414 }
415 }
416 Ok(())
417}
418
419fn constant_time_digest_eq(left: &[u8; 32], right: &[u8; 32]) -> bool {
420 left.iter().zip(right).fold(0_u8, |difference, (left, right)| difference | (left ^ right)) == 0
421}
422
423#[cfg(test)]
424mod tests {
425 #![allow(clippy::expect_used)]
426
427 #[cfg(unix)]
428 use super::{load_http_principals, load_secret_file};
429 use super::{parse_bool, HttpPrincipal};
430
431 #[test]
432 fn boolean_parser_is_consistent() {
433 assert_eq!(parse_bool(" TRUE "), Some(true));
434 assert_eq!(parse_bool("yes"), Some(true));
435 assert_eq!(parse_bool("off"), Some(false));
436 assert_eq!(parse_bool("false"), Some(false));
437 assert_eq!(parse_bool("maybe"), None);
438 }
439
440 #[test]
441 fn principal_keeps_only_a_digest_and_matches_constant_time() {
442 let token = "0123456789abcdef0123456789abcdef";
443 let principal = HttpPrincipal::new("chatgpt", token, false).expect("valid principal");
444 assert!(principal.matches_token(token));
445 assert!(!principal.matches_token("0123456789abcdef0123456789abcdeg"));
446 assert!(!format!("{principal:?}").contains(token));
447 }
448
449 #[test]
450 fn read_budget_parsing_has_safe_defaults_bounds_and_opt_outs() {
451 use super::{ReadResponseBudget, DEFAULT_READ_CALL_BUDGET_BYTES};
452 use std::time::Duration;
453
454 let defaults = ReadResponseBudget::default();
455 assert_eq!(defaults.call_bytes, Some(DEFAULT_READ_CALL_BUDGET_BYTES));
456 assert_eq!(defaults.file_bytes, Some(32 * 1024));
457 assert_eq!(defaults.unchanged_ttl, Duration::ZERO);
458
459 let invalid = ReadResponseBudget::parse(Some("lots"), Some("-1"), Some("soon"));
460 assert_eq!(invalid, defaults);
461
462 let disabled = ReadResponseBudget::parse(Some("0"), Some("0"), Some("0"));
463 assert_eq!(disabled.call_bytes, None);
464 assert_eq!(disabled.file_bytes, None);
465 assert!(disabled.unchanged_ttl.is_zero());
466
467 let clamped = ReadResponseBudget::parse(Some("10"), Some("999999"), None);
470 assert_eq!(clamped.call_bytes, Some(1024));
471 assert_eq!(clamped.file_bytes, Some(1024));
472 let file_only = ReadResponseBudget::parse(Some("0"), Some("4096"), None);
473 assert_eq!((file_only.call_bytes, file_only.file_bytes), (None, Some(4096)));
474 }
475
476 #[cfg(unix)]
477 #[test]
478 fn secret_file_requires_user_only_permissions() {
479 use std::os::unix::fs::PermissionsExt as _;
480
481 let directory = tempfile::TempDir::new().expect("temp directory");
482 let secret = directory.path().join("token");
483 std::fs::write(&secret, "0123456789abcdef0123456789abcdef\n").expect("write token");
484 std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o644))
485 .expect("set broad permissions");
486 assert!(load_secret_file(&secret).is_err());
487
488 std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o600))
489 .expect("set private permissions");
490 assert_eq!(
491 load_secret_file(&secret).expect("private token"),
492 "0123456789abcdef0123456789abcdef"
493 );
494 }
495
496 #[cfg(unix)]
497 #[test]
498 fn principal_toml_loads_independent_file_backed_identities() {
499 use std::os::unix::fs::PermissionsExt as _;
500
501 let directory = tempfile::TempDir::new().expect("temp directory");
502 let left = directory.path().join("left-token");
503 let right = directory.path().join("right-token");
504 for (path, value) in [
505 (&left, "left-0123456789abcdef0123456789abcdef"),
506 (&right, "right-0123456789abcdef0123456789abcdef"),
507 ] {
508 std::fs::write(path, value).expect("write token");
509 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
510 .expect("private token permissions");
511 }
512 let config = directory.path().join("principals.toml");
513 std::fs::write(
514 &config,
515 format!(
516 "[[principals]]\nname = \"left\"\ntoken_file = {left:?}\ntool_profile = \"terminal\"\n\n[[principals]]\nname = \"right\"\ntoken_file = {right:?}\nallowed_tools = [\"Initialize\", \"ReadFiles\"]\n"
517 ),
518 )
519 .expect("write principal config");
520
521 let principals =
522 load_http_principals(Some(&config), None, None, false).expect("load principals");
523 assert_eq!(principals.len(), 2);
524 assert!(principals[0].matches_token("left-0123456789abcdef0123456789abcdef"));
525 assert!(principals[1].matches_token("right-0123456789abcdef0123456789abcdef"));
526 assert_ne!(principals[0].session_prefix(), principals[1].session_prefix());
527 assert_eq!(
528 principals[0].tool_policy().names().collect::<Vec<_>>(),
529 vec!["Initialize", "BashCommand", "Execution", "Agent"]
530 );
531 assert_eq!(
532 principals[1].tool_policy().names().collect::<Vec<_>>(),
533 vec!["Initialize", "ReadFiles"]
534 );
535 }
536}