Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/rules/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

- 严格遵守“只读”“只出报告”“不改代码”等范围限制。
- API Key、OAuth token、Keychain、SSH 私钥、账号数据库和私人日志不得读取、打印、复制、修改或删除。
- 不检查或修改用户真实 `~/.claude-science`诊断时也不改真实 `~/.csswitch/skills` 或其他用户 Skill 数据。
- 不枚举、读取或修改用户真实 `~/.claude-science` 数据。唯一的 runtime 例外是按产品合同只读校验并执行精确文件 `~/.claude-science/bin/claude-science`;只能检查该文件的路径边界、metadata、可执行性和 `--version`,启动时必须注入 CSSwitch 独立 HOME/data-dir,不能访问同目录中的账号、OAuth、项目、会话、设置或数据库。诊断时也不改真实 `~/.csswitch/skills` 或其他用户 Skill 数据。
- 自动 runtime 检查必须使用临时外层 `HOME`、隔离 data-dir、假密钥 / 假 `security`、动态端口和可精确归属的进程。
- `8765` 视为用户真实 Science 的保留端口;只有护栏需要确认监听身份时才只读观察。
- 未经明确授权,不把 `/Applications/CSSwitch.app` 当开发测试目标,也不替换它。
Expand Down
4 changes: 2 additions & 2 deletions .agents/rules/science-runtime.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Science runtime 规则

- Science executable、持久 data-dir、版本 runtime 资源、组织数据和监听进程是不同事实。
- 新启动通常使用用户当前安装的官方 Claude Science App executable,并复用 CSSwitch 隔离 data-dir。
- 新启动优先只读复用官方 updater 已下载的 `~/.claude-science/bin/claude-science`,不存在或未通过安全版本预检时才使用官方 Claude Science App executable;两者都必须复用 CSSwitch 隔离 data-dir。
- `SCIENCE_BIN` 仅是显式开发 override;无效时 fail closed。历史缓存绝不能隐式回退。
- 不从真实 `~/.claude-science` 复制 runtime 资产,不下载或升级 Science;保持 `--no-auto-update`,除非产品合同另行批准
- 不从真实 `~/.claude-science` 复制 runtime 资产,不读取 executable 之外的真实数据,不下载或升级 Science;隔离模式保持 `--no-auto-update`,更新由官方模式负责
- Science 与 CSSwitch Gateway 均绑定 loopback;引入或暗示 `0.0.0.0` 需要单独的安全和产品决策。
- 端口占用或 `status` 成功不能单独证明 runtime 身份;需结合 executable、data-dir、监听 PID 和受管启动身份。
- 已健康 daemon 不因版本探测或可选功能漂移而强制重启。
Expand Down
7 changes: 4 additions & 3 deletions .agents/rules/system-ssh.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# 系统 SSH 规则

- 系统 SSH 配置复用默认关闭,必须由用户明确 opt-in。
- bridge 只能让系统 OpenSSH 读取真实 `~/.ssh/config`;不得复制或链接整个 `.ssh`,不得暴露私钥内容。为满足 Science 启动前 Host 校验,opt-in 时可在隔离 HOME 写一个 CSSwitch 管理的 `0600` 普通 config stub,其内容仅为指向真实 config 的绝对 `Include`。
- config stub 不得是 symlink,不得复制真实 config 内容。启用时遇到外来文件、symlink 或特殊文件必须 fail closed;关闭授权时只删除能严格识别为 CSSwitch 生成的 stub。
- bridge 的执行面只能让系统 OpenSSH 读取真实 `~/.ssh/config`;不得复制或链接整个 `.ssh`,不得暴露私钥内容。为兼容不展开 `Include` 的 Science Host 枚举器,opt-in 时可在隔离 HOME 写一个 CSSwitch 管理的 `0600` 普通 config stub:先写指向真实 config 的绝对 `Include`,再只投影真实顶层 config 中满足安全 token 语法的显式 Host alias。
- config stub 不得是 symlink,不得复制 HostName、User、Port、IdentityFile、ProxyCommand、Match 或其他真实连接配置。Host alias 投影必须过滤通配符/否定 pattern、稳定去重并有数量/大小上限;提取失败必须阻断 opt-in 启动,不能静默退回 Include-only。
- 启用时遇到外来文件、伪造 marker、symlink 或特殊文件必须 fail closed;v1 Include-only stub 可原子迁移,关闭授权时只删除能严格识别为 CSSwitch 生成的 v1/v2 stub。
- 启用设置时必须先验证真实 config;后续启动必须再次验证 config 与 packaged wrapper,任何一项缺失或不安全都应 fail closed。Science 启动后的单次 `ssh` 命令失败才只影响该命令。
- 不启动 `sshd`、不开启 Remote Login、不改防火墙,也不增加公网监听。
- 这是行为授权而非单文件授权:OpenSSH 的 `Include`、`IdentityFile`、`IdentityAgent`、`ProxyCommand` 和 `Match exec` 可能按原生语义访问其他文件、Agent 或命令。
- SSH 能力必须与 inference Gateway 暴露、真实账号凭证处理分离。
- 特定真实服务器或用户 SSH 配置的连通性需要独立授权和证据,不能由 wrapper 测试推断。
- 错误与证据不得打印私钥路径、config 内容、agent 数据或其他敏感信息。
- 错误与证据不得打印真实 Host alias、私钥路径、config 连接内容、agent 数据或其他敏感信息。
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,10 @@ CSSwitch 当前聚焦于安全地把外部 Skill 接入隔离 Science,而不

## 安全与隔离

- 第三方模式使用独立 HOME、data-dir 和本地回环 Gateway,不读取或修改真实 Claude 登录与 Science 数据。
- 第三方模式使用独立 HOME、data-dir 和本地回环 Gateway,不读取或修改真实 Claude 登录与 Science 数据;它可只读复用官方已下载的同一 Science executable,但不共享登录、项目或会话
- API Key 保存在本机 `~/.csswitch/config.json`,文件权限为 `0600`;凭据不会写入日志。
- 官方 Claude 模式会停止第三方代理链路,再打开真实 Science。
- CSSwitch 不下载、固定或自动升级 Claude Science;启动时优先使用当前安装的官方 App。
- CSSwitch 不下载、固定或自动升级 Claude Science;启动时优先复用官方已下载的 runtime,校验失败时才回退当前安装的官方 App。

## 当前边界

Expand Down
191 changes: 164 additions & 27 deletions desktop/src-tauri/src/runtime/science.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub(crate) const CACHED_ONCE_CHOICE: &str = "cached_once";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ScienceRuntimeSource {
Explicit,
OfficialDownloaded,
InstalledApp,
CachedOnce,
}
Expand All @@ -28,6 +29,7 @@ impl ScienceRuntimeSource {
pub(crate) fn code(self) -> &'static str {
match self {
Self::Explicit => "explicit",
Self::OfficialDownloaded => "official_downloaded",
Self::InstalledApp => "installed_app",
Self::CachedOnce => "cached_once",
}
Expand Down Expand Up @@ -237,6 +239,28 @@ fn cached_science_bin(data_dir: &Path) -> PathBuf {
data_dir.join("bin").join("claude-science")
}

fn official_downloaded_science_bin_for_home(home: &Path) -> Option<PathBuf> {
if !home.is_absolute() {
return None;
}
let path = cached_science_bin(&home.join(".claude-science"));
is_executable_file(&path).then_some(path)
}

#[cfg(not(feature = "acceptance-build"))]
fn official_downloaded_science_bin() -> Option<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from)?;
official_downloaded_science_bin_for_home(&home)
}

// Acceptance artifacts must never inspect the real user's Science data root.
// Path-injected unit tests below cover this candidate without weakening that
// build-time isolation contract.
#[cfg(feature = "acceptance-build")]
fn official_downloaded_science_bin() -> Option<PathBuf> {
None
}

fn safe_science_version(path: &Path) -> Option<String> {
let output = Command::new(path)
.arg("--version")
Expand Down Expand Up @@ -291,6 +315,7 @@ fn science_runtime_preflight_for_paths(
science_runtime_preflight_for_paths_cached(
data_dir,
explicit_bin,
None,
app_bin,
&ScienceVersionCache::default(),
)
Expand All @@ -299,6 +324,7 @@ fn science_runtime_preflight_for_paths(
fn science_runtime_preflight_for_paths_cached(
data_dir: &Path,
explicit_bin: Option<&Path>,
official_downloaded_bin: Option<&Path>,
app_bin: &Path,
version_cache: &ScienceVersionCache,
) -> Result<Value, String> {
Expand All @@ -317,6 +343,17 @@ fn science_runtime_preflight_for_paths_cached(
"download_url": SCIENCE_DOWNLOAD_URL,
}));
}
if let Some(bin) = official_downloaded_bin {
if let Some(version) = version_cache.version(bin) {
return Ok(json!({
"status": "installed_ready",
"selected_source": ScienceRuntimeSource::OfficialDownloaded.code(),
"selected_version": version,
"cached_version": Value::Null,
"download_url": SCIENCE_DOWNLOAD_URL,
}));
}
}
if let Some(version) = version_cache.version(app_bin) {
return Ok(json!({
"status": "installed_ready",
Expand Down Expand Up @@ -346,25 +383,35 @@ fn science_runtime_preflight_for_paths_cached(
}))
}

fn available_science_runtime_preflight(
version_cache: &ScienceVersionCache,
) -> Result<Value, String> {
let data_dir = sandbox_data_dir();
let explicit = explicit_science_bin()?;
let official_downloaded = if explicit.is_none() {
official_downloaded_science_bin()
} else {
None
};
science_runtime_preflight_for_paths_cached(
&data_dir,
explicit.as_deref(),
official_downloaded.as_deref(),
Path::new(SCIENCE_BIN),
version_cache,
)
}

pub(crate) fn science_runtime_preflight(
version_cache: &ScienceVersionCache,
confirmed_stopped: Option<&ScienceRuntimeIdentity>,
) -> Result<Value, String> {
if let Ok(cfg) = config::load_from(&config::default_dir()) {
if let Some(runtime) = confirmed_stopped {
if runtime.source != ScienceRuntimeSource::CachedOnce
&& !loopback_port_accepts_tcp(cfg.sandbox_port)
{
if let Some(version) = version_cache.version(&runtime.path) {
return Ok(json!({
"status": "installed_ready",
"selected_source": runtime.source.code(),
"selected_version": version,
"cached_version": Value::Null,
"download_url": SCIENCE_DOWNLOAD_URL,
}));
}
}
if confirmed_stopped.is_some() && !loopback_port_accepts_tcp(cfg.sandbox_port) {
// A stopped identity proves ownership, not future selection. Resolve
// candidates again so an official update that appeared since the
// prior stop wins over a remembered App fallback.
return available_science_runtime_preflight(version_cache);
}
let (state, runtime) = probe_sandbox_runtime_cached(cfg.sandbox_port, version_cache)?;
if state == SandboxScienceState::RunningHealthy {
Expand All @@ -378,14 +425,7 @@ pub(crate) fn science_runtime_preflight(
}));
}
}
let data_dir = sandbox_data_dir();
let explicit = explicit_science_bin()?;
science_runtime_preflight_for_paths_cached(
&data_dir,
explicit.as_deref(),
Path::new(SCIENCE_BIN),
version_cache,
)
available_science_runtime_preflight(version_cache)
}

#[cfg(test)]
Expand All @@ -398,6 +438,7 @@ fn select_science_runtime_for_paths(
select_science_runtime_for_paths_cached(
data_dir,
explicit_bin,
None,
app_bin,
choice,
&ScienceVersionCache::default(),
Expand All @@ -407,6 +448,7 @@ fn select_science_runtime_for_paths(
fn select_science_runtime_for_paths_cached(
data_dir: &Path,
explicit_bin: Option<&Path>,
official_downloaded_bin: Option<&Path>,
app_bin: &Path,
choice: Option<&str>,
version_cache: &ScienceVersionCache,
Expand All @@ -424,6 +466,15 @@ fn select_science_runtime_for_paths_cached(
version: Some(version),
});
}
if let Some(bin) = official_downloaded_bin {
if let Some(version) = version_cache.version(bin) {
return Ok(ScienceRuntimeIdentity {
path: bin.to_path_buf(),
source: ScienceRuntimeSource::OfficialDownloaded,
version: Some(version),
});
}
}
if let Some(version) = version_cache.version(app_bin) {
return Ok(ScienceRuntimeIdentity {
path: app_bin.to_path_buf(),
Expand Down Expand Up @@ -454,9 +505,15 @@ pub(crate) fn select_science_runtime_cached(
) -> Result<ScienceRuntimeIdentity, String> {
let data_dir = sandbox_data_dir();
let explicit = explicit_science_bin()?;
let official_downloaded = if explicit.is_none() {
official_downloaded_science_bin()
} else {
None
};
select_science_runtime_for_paths_cached(
&data_dir,
explicit.as_deref(),
official_downloaded.as_deref(),
Path::new(SCIENCE_BIN),
choice,
version_cache,
Expand All @@ -474,6 +531,15 @@ fn runtime_probe_candidates(
)]);
}
let mut candidates = Vec::new();
if let Some(official_downloaded) = official_downloaded_science_bin() {
if version_cache.version(&official_downloaded).is_some() {
candidates.push(runtime_identity(
official_downloaded,
ScienceRuntimeSource::OfficialDownloaded,
version_cache,
));
}
}
let app = PathBuf::from(SCIENCE_BIN);
if version_cache.version(&app).is_some() {
candidates.push(runtime_identity(
Expand Down Expand Up @@ -834,8 +900,9 @@ mod tests {
use std::process::{ExitStatus, Output};

use super::{
classify_known_runtime_state, classify_sandbox_state, first_http_url, runtime_status_value,
sandbox_home, sandbox_running_ours, sandbox_url, science_runtime_preflight_for_paths,
classify_known_runtime_state, classify_sandbox_state, first_http_url,
official_downloaded_science_bin_for_home, runtime_status_value, sandbox_home,
sandbox_running_ours, sandbox_url, science_runtime_preflight_for_paths,
science_runtime_preflight_for_paths_cached, science_status_running,
select_science_runtime_for_paths, select_science_runtime_for_paths_cached,
settings_change_needs_teardown, stop_runtime_from_probe, trusted_science_status,
Expand Down Expand Up @@ -901,16 +968,16 @@ mod tests {
let cache = ScienceVersionCache::default();

let preflight =
science_runtime_preflight_for_paths_cached(&data_dir, None, &app_bin, &cache)?;
science_runtime_preflight_for_paths_cached(&data_dir, None, None, &app_bin, &cache)?;
assert_eq!(preflight["selected_version"], "claude-science cache-v1");
let selected =
select_science_runtime_for_paths_cached(&data_dir, None, &app_bin, None, &cache)?;
select_science_runtime_for_paths_cached(&data_dir, None, None, &app_bin, None, &cache)?;
assert_eq!(selected.version.as_deref(), Some("claude-science cache-v1"));
assert_eq!(fs::read_to_string(&count)?, "1");

write_counted_version_bin(&app_bin, &count, "claude-science cache-version-two")?;
let selected =
select_science_runtime_for_paths_cached(&data_dir, None, &app_bin, None, &cache)?;
select_science_runtime_for_paths_cached(&data_dir, None, None, &app_bin, None, &cache)?;
assert_eq!(
selected.version.as_deref(),
Some("claude-science cache-version-two")
Expand All @@ -926,6 +993,76 @@ mod tests {
Ok(())
}

#[test]
fn official_downloaded_runtime_wins_without_sharing_official_data(
) -> Result<(), Box<dyn std::error::Error>> {
let root = unique_temp_dir("science-official-downloaded-selection")?;
let official_home = root.join("official-home");
let official_bin = official_home
.join(".claude-science")
.join("bin")
.join("claude-science");
let data_dir = root.join("isolated-home").join(".claude-science");
let cached_bin = data_dir.join("bin").join("claude-science");
let app_bin = root.join("app-claude-science");
let isolated_marker = data_dir.join("isolated-state.txt");
write_fake_version_bin(&official_bin, 0o755, "fake-official-downloaded-2")?;
write_fake_version_bin(&app_bin, 0o755, "fake-app-1")?;
write_fake_version_bin(&cached_bin, 0o755, "fake-cache-3")?;
fs::write(&isolated_marker, "keep-isolated")?;
let cache = ScienceVersionCache::default();

assert_eq!(
official_downloaded_science_bin_for_home(&official_home).as_deref(),
Some(official_bin.as_path())
);
assert!(
official_downloaded_science_bin_for_home(std::path::Path::new("relative-home"))
.is_none()
);

let preflight = science_runtime_preflight_for_paths_cached(
&data_dir,
None,
Some(&official_bin),
&app_bin,
&cache,
)?;
assert_eq!(preflight["selected_source"], "official_downloaded");
assert_eq!(preflight["selected_version"], "fake-official-downloaded-2");

let selected = select_science_runtime_for_paths_cached(
&data_dir,
None,
Some(&official_bin),
&app_bin,
Some(CACHED_ONCE_CHOICE),
&cache,
)?;
assert_eq!(selected.path, official_bin);
assert_eq!(selected.source, ScienceRuntimeSource::OfficialDownloaded);
assert_eq!(
selected.version.as_deref(),
Some("fake-official-downloaded-2")
);
assert_eq!(fs::read_to_string(&isolated_marker)?, "keep-isolated");

fs::set_permissions(&selected.path, fs::Permissions::from_mode(0o644))?;
let fallback = select_science_runtime_for_paths_cached(
&data_dir,
None,
Some(&selected.path),
&app_bin,
None,
&cache,
)?;
assert_eq!(fallback.source, ScienceRuntimeSource::InstalledApp);
assert_eq!(fallback.version.as_deref(), Some("fake-app-1"));

fs::remove_dir_all(root)?;
Ok(())
}

#[test]
fn science_status_running_accepts_compact_and_spaced_json() {
assert!(science_status_running(&status_output(
Expand Down
Loading