Synced from monorepo
Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
This commit is contained in:
parent
8adf9013a0
commit
98c3b2438a
225 changed files with 18836 additions and 7156 deletions
|
|
@ -69,19 +69,50 @@ pub fn is_cli_chat_proxy_url(url: &str) -> bool {
|
|||
}
|
||||
false
|
||||
}
|
||||
/// True for first-party xAI endpoints (`*.x.ai`, cli-chat-proxy, and optional
|
||||
/// non-production first-party hosts when that feature is enabled).
|
||||
/// True for xAI-operated endpoints (`*.x.ai`, cli-chat-proxy, and optional
|
||||
/// non-production xAI hosts when that feature is enabled).
|
||||
/// `disable_api_key_auth` refuses keys only for these; other hosts are BYOK and
|
||||
/// exempt. Safe against invalid URLs and suffix attacks (`evil-x.ai.example`).
|
||||
pub fn is_first_party_xai_url(url: &str) -> bool {
|
||||
///
|
||||
/// Scheme-agnostic so credential *refusal* fails closed. To decide where to
|
||||
/// *attach* a credential, use [`is_xai_api_bearer_url`].
|
||||
pub fn is_xai_api_url(url: &str) -> bool {
|
||||
is_xai_api_url_impl(url, false)
|
||||
}
|
||||
/// Like [`is_xai_api_url`], but requires `https` on every arm, so a
|
||||
/// session bearer is never attached to a cleartext endpoint, including loopback
|
||||
/// (a co-located process could otherwise read a token sent to `http://localhost`).
|
||||
pub fn is_xai_api_bearer_url(url: &str) -> bool {
|
||||
is_xai_api_url_impl(url, true)
|
||||
}
|
||||
fn is_xai_api_url_impl(url: &str, require_https: bool) -> bool {
|
||||
if require_https {
|
||||
let Ok(parsed) = reqwest::Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
if parsed.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
if is_loopback_host(&parsed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if is_cli_chat_proxy_url(url) {
|
||||
return true;
|
||||
}
|
||||
reqwest::Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|h| h.to_owned()))
|
||||
.and_then(|u| u.host_str().map(str::to_owned))
|
||||
.is_some_and(|host| host == "x.ai" || host.ends_with(".x.ai"))
|
||||
}
|
||||
fn is_loopback_host(parsed: &reqwest::Url) -> bool {
|
||||
match parsed.host() {
|
||||
Some(url::Host::Domain(host)) => host == "localhost",
|
||||
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
|
||||
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
/// Truncate a string to at most `max_chars` characters.
|
||||
/// Slices at char boundaries so multi-byte UTF-8 never panics.
|
||||
pub fn truncate(s: &str, max_chars: usize) -> &str {
|
||||
|
|
@ -237,25 +268,39 @@ mod tests {
|
|||
));
|
||||
}
|
||||
#[test]
|
||||
fn test_is_first_party_xai_url() {
|
||||
assert!(is_first_party_xai_url("https://api.x.ai/v1"));
|
||||
assert!(is_first_party_xai_url(
|
||||
"https://api.x.ai/v1/chat/completions"
|
||||
));
|
||||
assert!(is_first_party_xai_url("https://x.ai"));
|
||||
assert!(is_first_party_xai_url(
|
||||
fn test_is_xai_api_url() {
|
||||
assert!(is_xai_api_url("https://api.x.ai/v1"));
|
||||
assert!(is_xai_api_url("https://api.x.ai/v1/chat/completions"));
|
||||
assert!(is_xai_api_url("https://x.ai"));
|
||||
assert!(is_xai_api_url(
|
||||
"https://cli-chat-proxy.grok.com/v1/chat/completions"
|
||||
));
|
||||
assert!(!is_first_party_xai_url("https://api.openai.com/v1"));
|
||||
assert!(!is_first_party_xai_url("https://api.anthropic.com/v1"));
|
||||
assert!(!is_first_party_xai_url(
|
||||
"https://generativelanguage.googleapis.com"
|
||||
assert!(!is_xai_api_url("https://api.openai.com/v1"));
|
||||
assert!(!is_xai_api_url("https://api.anthropic.com/v1"));
|
||||
assert!(!is_xai_api_url("https://generativelanguage.googleapis.com"));
|
||||
assert!(!is_xai_api_url("https://api.x.ai.evil.example/v1"));
|
||||
assert!(!is_xai_api_url("https://evil-x.ai.attacker.com/v1"));
|
||||
assert!(!is_xai_api_url("https://prefixx.ai/v1"));
|
||||
assert!(!is_xai_api_url("not-a-url"));
|
||||
assert!(!is_xai_api_url(""));
|
||||
assert!(is_xai_api_url("http://api.x.ai/v1"));
|
||||
assert!(is_xai_api_url("http://localhost:11434/v1"));
|
||||
}
|
||||
#[test]
|
||||
fn test_is_xai_api_bearer_url() {
|
||||
assert!(is_xai_api_bearer_url("https://api.x.ai/v1"));
|
||||
assert!(!is_xai_api_bearer_url("http://api.x.ai/v1"));
|
||||
assert!(!is_xai_api_bearer_url("http://localhost:11434/v1"));
|
||||
{
|
||||
assert!(!is_xai_api_bearer_url("https://localhost:11434/v1"));
|
||||
assert!(!is_xai_api_bearer_url("https://127.0.0.2:11434/v1"));
|
||||
assert!(!is_xai_api_bearer_url("https://[::1]:11434/v1"));
|
||||
}
|
||||
assert!(is_xai_api_bearer_url("https://API.X.AI/v1"));
|
||||
assert!(!is_xai_api_bearer_url(
|
||||
"https://api.x.ai@attacker.example/v1"
|
||||
));
|
||||
assert!(!is_first_party_xai_url("https://api.x.ai.evil.example/v1"));
|
||||
assert!(!is_first_party_xai_url("https://evil-x.ai.attacker.com/v1"));
|
||||
assert!(!is_first_party_xai_url("https://prefixx.ai/v1"));
|
||||
assert!(!is_first_party_xai_url("not-a-url"));
|
||||
assert!(!is_first_party_xai_url(""));
|
||||
assert!(!is_xai_api_bearer_url("https://х.ai/v1"));
|
||||
}
|
||||
#[test]
|
||||
fn test_truncate() {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ use std::io::{self, Write};
|
|||
use std::path::Path;
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
|
||||
/// Creates or opens a file with secure permissions (owner read/write only).
|
||||
///
|
||||
|
|
@ -58,17 +58,20 @@ pub fn write_secure_file(path: &Path, contents: &[u8]) -> io::Result<()> {
|
|||
file.write_all(contents)?;
|
||||
file.flush()?;
|
||||
|
||||
// On Windows, we need to set permissions after file creation
|
||||
#[cfg(windows)]
|
||||
{
|
||||
set_windows_secure_permissions(path)?;
|
||||
}
|
||||
// Re-assert owner-only bits: `OpenOptions::mode` only applies on create,
|
||||
// so an existing world-readable file would otherwise keep open perms.
|
||||
ensure_owner_only_permissions(path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens a file for writing with secure permissions set during creation (Unix)
|
||||
/// or prepares it for permission setting after creation (Windows).
|
||||
///
|
||||
/// Callers that write secret material should also call
|
||||
/// [`ensure_owner_only_permissions`] after the write (or use
|
||||
/// [`write_secure_file`]), because `mode(0o600)` only applies when the file
|
||||
/// is newly created — not when truncating an existing path.
|
||||
pub fn open_secure_file(path: &Path) -> io::Result<File> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.truncate(true).write(true).create(true);
|
||||
|
|
@ -82,6 +85,44 @@ pub fn open_secure_file(path: &Path) -> io::Result<File> {
|
|||
options.open(path)
|
||||
}
|
||||
|
||||
/// Ensure `path` is owner-read/write only (Unix `0o600` / Windows user ACL).
|
||||
///
|
||||
/// Best-effort on missing files (`NotFound` is ignored). Other errors
|
||||
/// propagate so callers can fail closed when tightening a secret store.
|
||||
///
|
||||
/// Use on **load** of credential files so a hand-copied or restored
|
||||
/// world-readable `auth.json` is tightened before the process continues.
|
||||
pub fn ensure_owner_only_permissions(path: &Path) -> io::Result<()> {
|
||||
match ensure_owner_only_permissions_inner(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_owner_only_permissions_inner(path: &Path) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
let mode = metadata.permissions().mode();
|
||||
if mode & 0o777 != 0o600 {
|
||||
let mut perms = metadata.permissions();
|
||||
perms.set_mode(0o600);
|
||||
std::fs::set_permissions(path, perms)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
set_windows_secure_permissions(path)
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = path;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets Windows-specific secure permissions on a file.
|
||||
///
|
||||
/// This function modifies the file's ACL to:
|
||||
|
|
@ -216,8 +257,6 @@ mod tests {
|
|||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_unix_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = temp_dir.path().join("test_perms.txt");
|
||||
|
||||
|
|
@ -228,4 +267,43 @@ mod tests {
|
|||
// Check that only owner has read/write (0o600), ignoring file type bits
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_owner_only_tightens_world_readable_file() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = temp_dir.path().join("loose.txt");
|
||||
fs::write(&file_path, b"secret").unwrap();
|
||||
let mut loose = fs::metadata(&file_path).unwrap().permissions();
|
||||
loose.set_mode(0o644);
|
||||
fs::set_permissions(&file_path, loose).unwrap();
|
||||
assert_eq!(
|
||||
fs::metadata(&file_path).unwrap().permissions().mode() & 0o777,
|
||||
0o644
|
||||
);
|
||||
|
||||
ensure_owner_only_permissions(&file_path).unwrap();
|
||||
assert_eq!(
|
||||
fs::metadata(&file_path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_secure_file_tightens_existing_world_readable_file() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = temp_dir.path().join("existing.txt");
|
||||
fs::write(&file_path, b"old").unwrap();
|
||||
let mut loose = fs::metadata(&file_path).unwrap().permissions();
|
||||
loose.set_mode(0o666);
|
||||
fs::set_permissions(&file_path, loose).unwrap();
|
||||
|
||||
write_secure_file(&file_path, b"new secret").unwrap();
|
||||
assert_eq!(
|
||||
fs::metadata(&file_path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&file_path).unwrap(), "new secret");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue