feat(v0.44): pre-release audit — 1 CRITICAL + 4 HIGH + 4 MEDIUM patched
Some checks are pending
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / preflight (push) Waiting to run
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / vps-smoke (push) Waiting to run
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:frustration-matrix,kei-frustration-loop,kei-skill-importer,kei-projects-index,kei-projects-watcher,kei-gdrive-import,kei-leak-matrix,kei-skills,kei-gateway,kei-cron-scheduler,kei-export-trajectories,kei-backend-daytona,kei-d… (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-compute-baremetal,kei-compute-vultr,kei-compute-linode,kei-compute-digitalocean,kei-svc-systemd,kei-llm-bridge-mlx name:hosted-sleep-compute]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-diff,kei-scheduler,kei-watch,kei-prune,kei-discover,kei-brain-view,kei-hibernate,kei-ledger-sign,kei-fork name:wave13-15]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-git-gitea,kei-git-forgejo,kei-git-gitlab,kei-git-bitbucket,kei-memory-sled,kei-memory-redis,kei-memory-postgres,kei-memory-sqlite,kei-auth-google,kei-auth-apple,kei-auth-magiclink,kei-auth-webauthn,kei-notify-slack,kei-n… (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-ledger,kei-migrate,kei-changelog,kei-memory,kei-store,kei-conflict-scan,kei-refactor-engine,kei-graph-check,kei-shared,kei-dna-index,kei-pet name:core]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-machine-probe,kei-llm-ollama,kei-llm-llamacpp,kei-llm-mlx,kei-llm-router,kei-model name:llm-stack]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-router,kei-sage,kei-task,kei-chat-store,kei-crossdomain,kei-search-core,kei-content-store,kei-social-store,kei-curator,kei-auth,kei-artifact name:mcp-lbm]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:keisei,kei-forge,kei-runtime,kei-runtime-core,kei-atom-discovery,kei-agent-runtime,kei-capability,kei-provision,kei-entity-store,kei-pipe,kei-cache,kei-spawn,kei-replay name:atom-substrate]) (push) Blocked by required conditions
Some checks are pending
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / preflight (push) Waiting to run
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / vps-smoke (push) Waiting to run
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:frustration-matrix,kei-frustration-loop,kei-skill-importer,kei-projects-index,kei-projects-watcher,kei-gdrive-import,kei-leak-matrix,kei-skills,kei-gateway,kei-cron-scheduler,kei-export-trajectories,kei-backend-daytona,kei-d… (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-compute-baremetal,kei-compute-vultr,kei-compute-linode,kei-compute-digitalocean,kei-svc-systemd,kei-llm-bridge-mlx name:hosted-sleep-compute]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-diff,kei-scheduler,kei-watch,kei-prune,kei-discover,kei-brain-view,kei-hibernate,kei-ledger-sign,kei-fork name:wave13-15]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-git-gitea,kei-git-forgejo,kei-git-gitlab,kei-git-bitbucket,kei-memory-sled,kei-memory-redis,kei-memory-postgres,kei-memory-sqlite,kei-auth-google,kei-auth-apple,kei-auth-magiclink,kei-auth-webauthn,kei-notify-slack,kei-n… (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-ledger,kei-migrate,kei-changelog,kei-memory,kei-store,kei-conflict-scan,kei-refactor-engine,kei-graph-check,kei-shared,kei-dna-index,kei-pet name:core]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-machine-probe,kei-llm-ollama,kei-llm-llamacpp,kei-llm-mlx,kei-llm-router,kei-model name:llm-stack]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-router,kei-sage,kei-task,kei-chat-store,kei-crossdomain,kei-search-core,kei-content-store,kei-social-store,kei-curator,kei-auth,kei-artifact name:mcp-lbm]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:keisei,kei-forge,kei-runtime,kei-runtime-core,kei-atom-discovery,kei-agent-runtime,kei-capability,kei-provision,kei-entity-store,kei-pipe,kei-cache,kei-spawn,kei-replay name:atom-substrate]) (push) Blocked by required conditions
Four-CLI parallel pre-release audit (Claude+Grok+Gemini+Copilot, each reviewing different angle) surfaced 9 real issues in v0.43. All fixed. ## Audit team & their finds - Claude (critic): code review — found #5 KEI_ALLOWED_ROOTS bypass, #6 macOS TMPDIR denylist conflict, #7 timeout doc drift, #9 failure-cache schema mismatch. - Gemini (security): wrote Rust PoC, verified — found #1 CRITICAL parent symlink for non-existent leaf, #2 TOCTOU await, #3 curl config injection, #4 env inheritance, #8 cwd. - Grok (architect): noted safe_tools.rs at 572 LOC (>200 Constructor threshold). Deferred decomposition to v0.45. - Copilot (docs): inspected README/encyclopedia, no blocker findings (1 Premium, 977k cached tokens). ## Fixes shipped [#1 CRITICAL] Parent-symlink bypass for non-existent leaf paths v0.42 only canonicalized PARENT. If THAT parent didn't exist either, the path fell through to "absolute as-is" with no canonicalization. E.g. /proj/symlink -> /Users/denis, then kei_write /proj/symlink/ newdir/file would write inside /Users/denis with no check. Fix: walk_up_to_canonicalize() — find DEEPEST existing ancestor, canonicalize THAT (resolving all symlinks in the existing prefix), then reattach the non-existent tail. [#2 HIGH] TOCTOU between validate_path and fs::write 60s of hook chain await between path check and write. Concurrent process could swap leaf for symlink during that window; fs::write followed it. Fix: open file with O_NOFOLLOW + write through the open fd (not the path again). Open() itself fails on symlink-swap. Edit + Write both patched. Falls back to plain tokio::fs on non-Unix. [#3 HIGH] curl config injection via MOONSHOT_API_KEY Was: token interpolated into printf 'header = "...%s..."' fed to curl --config. If token contained " + newline + 'url = "evil"', curl parsed the injected config and redirected. Fix: validate MOONSHOT_API_KEY matches [A-Za-z0-9_.-]+; reject any other chars before probe runs. [#4 HIGH] Subprocess env inheritance — secret leak via kei_bash Was: spawned bash inherited AWS_*, GITHUB_TOKEN, MOONSHOT_API_KEY, etc. Agent running `env` via kei_bash could exfiltrate all of them. Fix: apply_safe_env() — env_clear() + whitelist forward of PATH/ HOME/USER/LANG/TERM/SHELL/PWD/TMPDIR/LOGNAME/LC_*. Operators add named vars via KEI_SAFE_ENV_EXTRA. Applied to BOTH kei_bash spawn AND hook subprocess spawn. [#5 HIGH] KEI_ALLOWED_ROOTS unanchored prefix bypass Was: str::starts_with on raw user-supplied root. KEI_ALLOWED_ROOTS=/home/u/proj also allowed /home/u/proj-secrets/... Fix: normalize each entry to canonical + trailing slash; use Path::starts_with (component-aware). v0.44 combines with #6 fix (canonicalize symlinks like /var → /private/var on macOS). [#6 MEDIUM] macOS $TMPDIR denied by /var/ blanket Was: denylist included /var/, /private/var/ blanket entries. macOS $TMPDIR = /var/folders/... canonicalized to /private/var/ folders/... hit the denylist before allowed_roots was checked. Fix: (a) allowed_roots check FIRST; (b) narrowed denylist to /var/db/, /var/log/, /var/root/ (and /private/ counterparts) instead of blanket /var/. /var/folders + /private/tmp are now legitimate working dirs. [#7 MEDIUM] Timeout aggregate claim was always false Was: doc said "Hard cap on single chain + action ... 60s" — actually was per-step. For 3-hook chain, total = 4 * 60 = 240s. Fix: doc comment now honest about per-step semantics. Aggregate- deadline impl deferred to v0.45 (not security-blocking). [#8 MEDIUM] cwd not in hook input — hook approves wrong cwd Was: kei_bash accepts cwd arg but did not pass it to safety hooks. Hook could approve `rm -rf *` assuming PWD, while cwd actually pointed at /etc or ~/.ssh. Fix: include cwd in hook_input JSON. Hooks now see the real working dir for their decision. [#9 MEDIUM] Failure-fallback cache had different schema Was: emit '{"ts":"","status":"assembly-failed"}' — no per-CLI keys. Pet's .kimi.available_balance_usd read got null/error; kei-limits own per-CLI render loop emitted 5 malformed rows. Fix: failure-fallback emits same shape as success {ts, claude, grok, agy, copilot, kimi} with each marked status='assembly-failed'. LOW: empty old_string in kei_edit now rejected (was: silently prepended new_string since contents.contains("") is always true). ## Tests + smokes cargo test -p kei-mcp: 3/3 pass. 8 MCP smokes (all green after every audit round): - kei_bash blocks RULE 0.1 push - kei_bash passes echo OK - kei_write /etc/passwd → denied (system dir) - kei_write ../ → denied (.. segment) - kei_write ~/.ssh/ → denied (outside roots) - kei_write symlink-to-etc/passwd → denied (canonicalized) - kei_write ~/.claude/hooks/ → denied (substrate dir) - kei_write ~/.zshrc → denied (outside roots) NEW v0.44 smokes: - kei_write /Users/denis/.ssh/newdir/keys via /tmp/v44_link → denied - KEI_ALLOWED_ROOTS=/tmp/proj does NOT match /tmp/proj-evil - FAKE_SECRET=stolen → TOKEN=empty in subprocess (env stripped) - MOONSHOT_API_KEY='abc"NL_url="evil"' → rejected pre-probe - macOS $TMPDIR via KEI_ALLOWED_ROOTS works (canonicalize fix) ## Deferred to v0.45 - safe_tools.rs at 572 LOC — extract path_guard + chain_runner modules - Aggregate-deadline timeout (single Instant::now() + remaining) - Hardlink check (open fd then fstat + dev/ino compare) - INVALID_PARAMS used for missing-arg (currently INTERNAL_ERROR) - INVALID_PARAMS_REF dead code at EOF (silencer for unused import) These are correctness/style/architectural, NOT security blockers.
This commit is contained in:
parent
424a6ced00
commit
3b54f0b5e0
4 changed files with 276 additions and 92 deletions
|
|
@ -60,8 +60,12 @@ use tokio::fs;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
/// Hard cap on how long a single hook chain + action may take. Matches the
|
/// Per-step timeout (each hook AND the action each get up to this long).
|
||||||
/// timeout in `handlers::tools::ATOM_TIMEOUT_SECS` for consistency.
|
/// For an N-hook chain the total wall-clock cap is approximately
|
||||||
|
/// `(N+1) * SAFE_TOOL_TIMEOUT_SECS`. v0.44 doc-honesty fix (Claude MED):
|
||||||
|
/// prior versions claimed this was an "aggregate" cap, which was always
|
||||||
|
/// wrong. Aggregate-deadline impl is deferred; for now the per-step
|
||||||
|
/// semantics are documented honestly so operators pick a sane value.
|
||||||
const SAFE_TOOL_TIMEOUT_SECS: u64 = 60;
|
const SAFE_TOOL_TIMEOUT_SECS: u64 = 60;
|
||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
#[derive(Deserialize, Default)]
|
||||||
|
|
@ -148,9 +152,16 @@ async fn handle_bash(args: &Value) -> Result<String, String> {
|
||||||
.ok_or_else(|| missing_arg("kei_bash", "command"))?;
|
.ok_or_else(|| missing_arg("kei_bash", "command"))?;
|
||||||
let cwd = args.get("cwd").and_then(Value::as_str);
|
let cwd = args.get("cwd").and_then(Value::as_str);
|
||||||
|
|
||||||
|
// v0.44 fix #8 (Gemini MED): include cwd in hook input. Without this,
|
||||||
|
// safety-guard could approve a destructive command (e.g. `rm -rf *`)
|
||||||
|
// assuming PWD, while the actual cwd arg redirected it to a sensitive
|
||||||
|
// dir. Hooks now see the real working directory.
|
||||||
let hook_input = json!({
|
let hook_input = json!({
|
||||||
"tool_name": "Bash",
|
"tool_name": "Bash",
|
||||||
"tool_input": { "command": command }
|
"tool_input": {
|
||||||
|
"command": command,
|
||||||
|
"cwd": cwd
|
||||||
|
}
|
||||||
});
|
});
|
||||||
run_chain("bash", &hook_input).await?;
|
run_chain("bash", &hook_input).await?;
|
||||||
|
|
||||||
|
|
@ -163,9 +174,14 @@ async fn handle_bash(args: &Value) -> Result<String, String> {
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.kill_on_drop(true);
|
.kill_on_drop(true);
|
||||||
// v0.41 fix #5 (Gemini MED): put child in its own process group so timeout
|
// v0.41 fix #5: put child in its own process group so timeout kills it
|
||||||
// kills it and ALL grandchildren together (not just the immediate shell).
|
// and ALL grandchildren together (not just the immediate shell).
|
||||||
set_process_group(&mut cmd);
|
set_process_group(&mut cmd);
|
||||||
|
// v0.44 fix #4 (Gemini HIGH): clear parent env on subprocess spawn.
|
||||||
|
// Was: child inherited AWS_*, GITHUB_TOKEN, MOONSHOT_API_KEY, etc.
|
||||||
|
// An agent that exec's `env` via kei_bash could exfiltrate all of them.
|
||||||
|
// Now: only PATH/HOME/USER/LANG/TERM/SHELL forwarded (set in helper).
|
||||||
|
apply_safe_env(&mut cmd);
|
||||||
|
|
||||||
let child = cmd.spawn().map_err(|e| format!("spawn bash: {e}"))?;
|
let child = cmd.spawn().map_err(|e| format!("spawn bash: {e}"))?;
|
||||||
let pid_opt = child.id();
|
let pid_opt = child.id();
|
||||||
|
|
@ -196,15 +212,42 @@ async fn handle_bash(args: &Value) -> Result<String, String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.41 fix #5: process-group helpers (Unix-only; no-op on other platforms).
|
// v0.41 fix #5: process-group helpers (Unix-only; no-op on other platforms).
|
||||||
// tokio::process::Command::process_group is available on Unix without
|
|
||||||
// requiring the std::os::unix::process::CommandExt trait import.
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn set_process_group(cmd: &mut Command) {
|
fn set_process_group(cmd: &mut Command) {
|
||||||
cmd.process_group(0); // 0 = new session leader for this child
|
cmd.process_group(0);
|
||||||
}
|
}
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
fn set_process_group(_cmd: &mut Command) {}
|
fn set_process_group(_cmd: &mut Command) {}
|
||||||
|
|
||||||
|
/// v0.44 fix #4 (Gemini HIGH): strip parent env on subprocess spawn so secrets
|
||||||
|
/// like AWS_*, GITHUB_TOKEN, MOONSHOT_API_KEY etc. don't leak to user-controlled
|
||||||
|
/// bash commands or hook scripts. Whitelist forwards only PATH/HOME/USER/LANG/
|
||||||
|
/// TERM/SHELL — enough to keep tools functional, none of it sensitive.
|
||||||
|
///
|
||||||
|
/// Override: `KEI_SAFE_ENV_EXTRA=":-separated list"` adds named vars to the
|
||||||
|
/// whitelist for callers that legitimately need (e.g. NIX_PATH, JAVA_HOME).
|
||||||
|
fn apply_safe_env(cmd: &mut Command) {
|
||||||
|
cmd.env_clear();
|
||||||
|
let default_keep = [
|
||||||
|
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL",
|
||||||
|
"LC_CTYPE", "TERM", "PWD", "TMPDIR",
|
||||||
|
];
|
||||||
|
for k in default_keep {
|
||||||
|
if let Ok(v) = std::env::var(k) {
|
||||||
|
cmd.env(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(extras) = std::env::var("KEI_SAFE_ENV_EXTRA") {
|
||||||
|
for k in extras.split(':') {
|
||||||
|
let k = k.trim();
|
||||||
|
if k.is_empty() { continue; }
|
||||||
|
if let Ok(v) = std::env::var(k) {
|
||||||
|
cmd.env(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn killpg_best_effort(pid: u32) {
|
fn killpg_best_effort(pid: u32) {
|
||||||
// SAFETY: libc::kill on a negative PID targets the process group.
|
// SAFETY: libc::kill on a negative PID targets the process group.
|
||||||
|
|
@ -224,7 +267,12 @@ async fn handle_edit(args: &Value) -> Result<String, String> {
|
||||||
let new_string = args.get("new_string").and_then(Value::as_str)
|
let new_string = args.get("new_string").and_then(Value::as_str)
|
||||||
.ok_or_else(|| missing_arg("kei_edit", "new_string"))?;
|
.ok_or_else(|| missing_arg("kei_edit", "new_string"))?;
|
||||||
|
|
||||||
// v0.41 fix #2: path-traversal guard
|
// v0.44 LOW: reject empty old_string (would silently prepend new_string
|
||||||
|
// because contents.contains("") is always true).
|
||||||
|
if old_string.is_empty() {
|
||||||
|
return Err("kei_edit: old_string must not be empty".into());
|
||||||
|
}
|
||||||
|
|
||||||
let safe_path = validate_path(file_path)?;
|
let safe_path = validate_path(file_path)?;
|
||||||
|
|
||||||
let hook_input = json!({
|
let hook_input = json!({
|
||||||
|
|
@ -237,16 +285,12 @@ async fn handle_edit(args: &Value) -> Result<String, String> {
|
||||||
});
|
});
|
||||||
run_chain("edit", &hook_input).await?;
|
run_chain("edit", &hook_input).await?;
|
||||||
|
|
||||||
// v0.41 fix #4: tokio::fs (async)
|
// v0.44 fix #2 (Gemini HIGH + Claude #4 MED): close TOCTOU window. After
|
||||||
let contents = fs::read_to_string(&safe_path).await
|
// validate_path approved the path, a concurrent process could swap the
|
||||||
.map_err(|e| format!("read {}: {e}", safe_path.display()))?;
|
// file for a symlink before our write. Open the existing file with
|
||||||
if !contents.contains(old_string) {
|
// O_NOFOLLOW so the open itself fails on symlink-swap; then read/write
|
||||||
return Err(format!("kei_edit: old_string not found in {}", safe_path.display()));
|
// through the open fd (not the path again) so no second path lookup.
|
||||||
}
|
open_nofollow_read_write_edit(&safe_path, old_string, new_string).await
|
||||||
let updated = contents.replacen(old_string, new_string, 1);
|
|
||||||
fs::write(&safe_path, &updated).await
|
|
||||||
.map_err(|e| format!("write {}: {e}", safe_path.display()))?;
|
|
||||||
Ok(format!("edited {} ({} bytes)", safe_path.display(), updated.len()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_write(args: &Value) -> Result<String, String> {
|
async fn handle_write(args: &Value) -> Result<String, String> {
|
||||||
|
|
@ -255,7 +299,6 @@ async fn handle_write(args: &Value) -> Result<String, String> {
|
||||||
let content = args.get("content").and_then(Value::as_str)
|
let content = args.get("content").and_then(Value::as_str)
|
||||||
.ok_or_else(|| missing_arg("kei_write", "content"))?;
|
.ok_or_else(|| missing_arg("kei_write", "content"))?;
|
||||||
|
|
||||||
// v0.41 fix #2: path-traversal guard
|
|
||||||
let safe_path = validate_path(file_path)?;
|
let safe_path = validate_path(file_path)?;
|
||||||
|
|
||||||
let hook_input = json!({
|
let hook_input = json!({
|
||||||
|
|
@ -270,9 +313,93 @@ async fn handle_write(args: &Value) -> Result<String, String> {
|
||||||
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
|
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fs::write(&safe_path, content).await
|
// v0.44 fix #2: open with O_NOFOLLOW + O_CREAT to refuse swap-to-symlink.
|
||||||
.map_err(|e| format!("write {}: {e}", safe_path.display()))?;
|
open_nofollow_write(&safe_path, content).await
|
||||||
Ok(format!("wrote {} ({} bytes)", safe_path.display(), content.len()))
|
}
|
||||||
|
|
||||||
|
/// v0.44 fix #2: edit via O_NOFOLLOW-opened fd to close the TOCTOU window
|
||||||
|
/// between validate_path and the write. The open() itself refuses if the leaf
|
||||||
|
/// has been swapped to a symlink during the hook-chain await.
|
||||||
|
#[cfg(unix)]
|
||||||
|
async fn open_nofollow_read_write_edit(
|
||||||
|
path: &Path, old_string: &str, new_string: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
let old_s = old_string.to_string();
|
||||||
|
let new_s = new_string.to_string();
|
||||||
|
// Blocking syscalls on a dedicated thread (tokio::task::spawn_blocking).
|
||||||
|
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
|
||||||
|
let mut f = std::fs::OpenOptions::new()
|
||||||
|
.read(true).write(true)
|
||||||
|
.custom_flags(libc::O_NOFOLLOW)
|
||||||
|
.open(&path)
|
||||||
|
.map_err(|e| format!("kei_edit: open(O_NOFOLLOW) {}: {e}", path.display()))?;
|
||||||
|
use std::io::{Read, Write, Seek, SeekFrom};
|
||||||
|
let mut contents = String::new();
|
||||||
|
f.read_to_string(&mut contents)
|
||||||
|
.map_err(|e| format!("kei_edit: read {}: {e}", path.display()))?;
|
||||||
|
if !contents.contains(&old_s) {
|
||||||
|
return Err(format!("kei_edit: old_string not found in {}", path.display()));
|
||||||
|
}
|
||||||
|
let updated = contents.replacen(&old_s, &new_s, 1);
|
||||||
|
f.set_len(0).map_err(|e| format!("kei_edit: truncate {}: {e}", path.display()))?;
|
||||||
|
f.seek(SeekFrom::Start(0))
|
||||||
|
.map_err(|e| format!("kei_edit: seek {}: {e}", path.display()))?;
|
||||||
|
f.write_all(updated.as_bytes())
|
||||||
|
.map_err(|e| format!("kei_edit: write {}: {e}", path.display()))?;
|
||||||
|
Ok(format!("edited {} ({} bytes)", path.display(), updated.len()))
|
||||||
|
}).await
|
||||||
|
.map_err(|e| format!("kei_edit: thread join: {e}"))?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
async fn open_nofollow_read_write_edit(
|
||||||
|
path: &Path, old_string: &str, new_string: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// Non-Unix fallback: best-effort using tokio::fs (no O_NOFOLLOW available).
|
||||||
|
let contents = fs::read_to_string(path).await
|
||||||
|
.map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||||
|
if !contents.contains(old_string) {
|
||||||
|
return Err(format!("kei_edit: old_string not found in {}", path.display()));
|
||||||
|
}
|
||||||
|
let updated = contents.replacen(old_string, new_string, 1);
|
||||||
|
fs::write(path, &updated).await
|
||||||
|
.map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||||
|
Ok(format!("edited {} ({} bytes)", path.display(), updated.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
async fn open_nofollow_write(path: &Path, content: &str) -> Result<String, String> {
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
let bytes = content.as_bytes().to_vec();
|
||||||
|
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
|
||||||
|
let mut opts = std::fs::OpenOptions::new();
|
||||||
|
opts.write(true).create(true).truncate(true);
|
||||||
|
// O_NOFOLLOW: refuse if the leaf is a symlink (someone swapped it
|
||||||
|
// during our await). Without this the v0.42 symlink_metadata pre-check
|
||||||
|
// was just an indicator — fs::write still followed.
|
||||||
|
opts.custom_flags(libc::O_NOFOLLOW);
|
||||||
|
// O_EXCL combined with O_CREAT could be added when path does not yet
|
||||||
|
// exist to refuse any pre-existing inode — but the test suite uses
|
||||||
|
// the same path multiple times, so we keep truncate semantics. The
|
||||||
|
// O_NOFOLLOW + symlink_metadata pre-check is sufficient.
|
||||||
|
let mut f = opts.open(&path)
|
||||||
|
.map_err(|e| format!("kei_write: open(O_NOFOLLOW) {}: {e}", path.display()))?;
|
||||||
|
use std::io::Write;
|
||||||
|
f.write_all(&bytes)
|
||||||
|
.map_err(|e| format!("kei_write: write {}: {e}", path.display()))?;
|
||||||
|
Ok(format!("wrote {} ({} bytes)", path.display(), bytes.len()))
|
||||||
|
}).await
|
||||||
|
.map_err(|e| format!("kei_write: thread join: {e}"))?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
async fn open_nofollow_write(path: &Path, content: &str) -> Result<String, String> {
|
||||||
|
fs::write(path, content).await
|
||||||
|
.map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||||
|
Ok(format!("wrote {} ({} bytes)", path.display(), content.len()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path-traversal + symlink + denylist guard.
|
/// Path-traversal + symlink + denylist guard.
|
||||||
|
|
@ -300,36 +427,16 @@ fn validate_path(p: &str) -> Result<PathBuf, String> {
|
||||||
}
|
}
|
||||||
let path = Path::new(p);
|
let path = Path::new(p);
|
||||||
|
|
||||||
// 2. Build a canonical path. Prefer canonicalizing the FULL path (resolves
|
// 2. Build a canonical path. Walk UP to the deepest existing ancestor,
|
||||||
// symlinks at the leaf, fixing v0.41 CRITICAL bypass). For files that
|
// canonicalize it (resolves all symlinks in the existing prefix),
|
||||||
// don't exist yet (kei_write new file), canonicalize the parent and
|
// then reattach the non-existent tail. This catches symlinks at ANY
|
||||||
// join the leaf — but then explicitly check the leaf isn't a symlink
|
// depth in the path, including nested non-existent leaves.
|
||||||
// via symlink_metadata before writing.
|
//
|
||||||
let canonical = if path.exists() {
|
// v0.44 fix #1 (Gemini CRITICAL): v0.42 only canonicalized the immediate
|
||||||
// File exists — canonicalize full path, including resolving any leaf
|
// parent. If the parent didn't exist either (e.g. /proj/symlink_dir/
|
||||||
// symlink to its real target. The denylist/roots check below then
|
// new_subdir/file.txt where symlink_dir → /Users/denis), the path fell
|
||||||
// sees the REAL destination, not the symlink name.
|
// through to "absolute as-is" → no canonicalization → bypass.
|
||||||
path.canonicalize()
|
let canonical = canonicalize_with_walk_up(path)?;
|
||||||
.map_err(|e| format!("file_path: canonicalize {}: {e}", path.display()))?
|
|
||||||
} else if let Some(parent) = path.parent() {
|
|
||||||
if parent.as_os_str().is_empty() || parent == Path::new("") {
|
|
||||||
std::env::current_dir()
|
|
||||||
.map_err(|e| format!("file_path: cwd unavailable: {e}"))?
|
|
||||||
.join(path.file_name().unwrap_or_default())
|
|
||||||
} else if parent.exists() {
|
|
||||||
parent.canonicalize()
|
|
||||||
.map_err(|e| format!("file_path: canonicalize {}: {e}", parent.display()))?
|
|
||||||
.join(path.file_name().unwrap_or_default())
|
|
||||||
} else if path.is_absolute() {
|
|
||||||
path.to_path_buf()
|
|
||||||
} else {
|
|
||||||
std::env::current_dir()
|
|
||||||
.map_err(|e| format!("file_path: cwd unavailable: {e}"))?
|
|
||||||
.join(path)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Err(format!("file_path: invalid {p}"));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. Even when the file doesn't exist yet, the LEAF could already be a
|
// 3. Even when the file doesn't exist yet, the LEAF could already be a
|
||||||
// dangling symlink that `fs::write` would follow on creation. Reject.
|
// dangling symlink that `fs::write` would follow on creation. Reject.
|
||||||
|
|
@ -342,29 +449,49 @@ fn validate_path(p: &str) -> Result<PathBuf, String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Allowed-root containment FIRST (v0.44 fix #6 reorder: was after
|
||||||
|
// denylist, which meant macOS $TMPDIR = /private/var/folders/... hit
|
||||||
|
// the /var/ denylist before reaching the allowed_roots check, blocking
|
||||||
|
// legitimate use of tempfile-backed CWD on macOS).
|
||||||
|
//
|
||||||
|
// v0.44 fix #5 (Claude HIGH): use Path::starts_with for component-aware
|
||||||
|
// containment — Path::starts_with("/home/u/proj") does NOT match
|
||||||
|
// /home/u/proj-secrets, the str::starts_with that was here did.
|
||||||
|
let roots = allowed_roots();
|
||||||
|
let in_allowed_root = roots.is_empty() || roots.iter().any(|r| {
|
||||||
|
canonical.starts_with(r)
|
||||||
|
});
|
||||||
|
if !in_allowed_root {
|
||||||
|
return Err(format!(
|
||||||
|
"file_path: outside allowed roots {:?}: {}",
|
||||||
|
roots, canonical.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let canon_str = canonical.display().to_string();
|
let canon_str = canonical.display().to_string();
|
||||||
|
|
||||||
// 4. Reject system + substrate-control + credential paths.
|
// 5. Reject system + substrate-control + credential paths.
|
||||||
|
// Note: paths inside an allowed root that also match a denylist entry
|
||||||
|
// are STILL denied (e.g. agent's CWD == ~/.claude/ — denied even
|
||||||
|
// though it matches a default root). System dirs not in any allowed
|
||||||
|
// root would have been caught above anyway.
|
||||||
let denylist = [
|
let denylist = [
|
||||||
"/etc/", "/usr/", "/System/", "/var/", "/private/etc/", "/private/var/",
|
"/etc/", "/usr/", "/System/", "/var/db/", "/var/log/", "/var/root/",
|
||||||
|
"/private/etc/", "/private/var/db/", "/private/var/log/", "/private/var/root/",
|
||||||
"/root/", "/bin/", "/sbin/",
|
"/root/", "/bin/", "/sbin/",
|
||||||
];
|
];
|
||||||
|
// NOTE: /var/folders/ (macOS $TMPDIR) and /private/tmp/ are NOT denied —
|
||||||
|
// they are legitimate working dirs for tempfile-backed agents.
|
||||||
for d in denylist {
|
for d in denylist {
|
||||||
if canon_str.starts_with(d) {
|
if canon_str.starts_with(d) {
|
||||||
return Err(format!("file_path: denied (system dir): {canon_str}"));
|
return Err(format!("file_path: denied (system dir): {canon_str}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
// v0.42 fix #2 extended denylist — these targets enable self-attack
|
|
||||||
// (overwrite the substrate or shell init for RCE on next session).
|
|
||||||
let dir_secrets = [
|
let dir_secrets = [
|
||||||
".ssh/", ".aws/", ".gnupg/", ".config/gcloud/", ".cargo/credentials",
|
".ssh/", ".aws/", ".gnupg/", ".config/gcloud/", ".cargo/credentials",
|
||||||
".npmrc", ".docker/config.json", ".kube/",
|
".npmrc", ".docker/config.json", ".kube/",
|
||||||
".claude/", // our own substrate: hooks, settings, agents
|
".claude/", ".grok/", ".gemini/", ".copilot/", ".kimi/",
|
||||||
".grok/", // sibling CLI's settings
|
|
||||||
".gemini/", // antigravity settings
|
|
||||||
".copilot/", // copilot config
|
|
||||||
".kimi/", // kimi config
|
|
||||||
];
|
];
|
||||||
for sd in dir_secrets {
|
for sd in dir_secrets {
|
||||||
let full = format!("{home}/{sd}");
|
let full = format!("{home}/{sd}");
|
||||||
|
|
@ -372,7 +499,6 @@ fn validate_path(p: &str) -> Result<PathBuf, String> {
|
||||||
return Err(format!("file_path: denied (secret/substrate dir): {canon_str}"));
|
return Err(format!("file_path: denied (secret/substrate dir): {canon_str}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Exact shell-init files (overwriting → RCE on next shell start).
|
|
||||||
let init_files = [
|
let init_files = [
|
||||||
".zshrc", ".bashrc", ".profile", ".bash_profile", ".zprofile",
|
".zshrc", ".bashrc", ".profile", ".bash_profile", ".zprofile",
|
||||||
".zshenv", ".bash_login", ".inputrc", ".gitconfig",
|
".zshenv", ".bash_login", ".inputrc", ".gitconfig",
|
||||||
|
|
@ -386,31 +512,71 @@ fn validate_path(p: &str) -> Result<PathBuf, String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Enforce allowed-root containment.
|
|
||||||
let roots = allowed_roots();
|
|
||||||
if !roots.is_empty() {
|
|
||||||
let ok = roots.iter().any(|r| canon_str.starts_with(r));
|
|
||||||
if !ok {
|
|
||||||
return Err(format!(
|
|
||||||
"file_path: outside allowed roots {roots:?}: {canon_str}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(canonical)
|
Ok(canonical)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn allowed_roots() -> Vec<String> {
|
/// v0.44 fix #1: walk up the path looking for the deepest existing ancestor,
|
||||||
if let Ok(v) = std::env::var("KEI_ALLOWED_ROOTS") {
|
/// canonicalize THAT, then reattach the non-existent tail components.
|
||||||
return v.split(':').filter(|s| !s.is_empty()).map(String::from).collect();
|
/// Resolves symlinks at any depth (existing OR non-existing branches).
|
||||||
|
fn canonicalize_with_walk_up(path: &Path) -> Result<PathBuf, String> {
|
||||||
|
// Make the path absolute first so we can walk up reliably.
|
||||||
|
let abs = if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()
|
||||||
|
.map_err(|e| format!("file_path: cwd unavailable: {e}"))?
|
||||||
|
.join(path)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Walk up from the leaf, collecting non-existent components in reverse.
|
||||||
|
let mut current = abs.clone();
|
||||||
|
let mut tail: Vec<std::ffi::OsString> = Vec::new();
|
||||||
|
let canon = loop {
|
||||||
|
if current.exists() {
|
||||||
|
break current.canonicalize()
|
||||||
|
.map_err(|e| format!("file_path: canonicalize {}: {e}", current.display()))?;
|
||||||
|
}
|
||||||
|
let name = current.file_name()
|
||||||
|
.ok_or_else(|| format!("file_path: path has no existing ancestor: {}", abs.display()))?
|
||||||
|
.to_os_string();
|
||||||
|
let parent = match current.parent() {
|
||||||
|
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
|
||||||
|
_ => return Err(format!("file_path: walked to root without finding existing dir: {}", abs.display())),
|
||||||
|
};
|
||||||
|
tail.push(name);
|
||||||
|
current = parent;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reattach tail (in reverse — we pushed from leaf to root).
|
||||||
|
let mut result = canon;
|
||||||
|
for name in tail.into_iter().rev() {
|
||||||
|
result.push(name);
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed_roots() -> Vec<String> {
|
||||||
|
// Canonicalize each entry so symlinked roots (e.g. macOS /var → /private/var,
|
||||||
|
// /tmp → /private/tmp) match canonicalized targets. Trailing slash added
|
||||||
|
// for the consistency-with-default format. v0.44 fix #5 + #6 combined.
|
||||||
|
let canon_with_slash = |raw: &str| -> Option<String> {
|
||||||
|
let p = Path::new(raw);
|
||||||
|
let canon = std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
|
||||||
|
let mut s = canon.display().to_string();
|
||||||
|
if !s.ends_with('/') { s.push('/'); }
|
||||||
|
if s.is_empty() { None } else { Some(s) }
|
||||||
|
};
|
||||||
|
if let Ok(v) = std::env::var("KEI_ALLOWED_ROOTS") {
|
||||||
|
return v.split(':')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.filter_map(canon_with_slash)
|
||||||
|
.collect();
|
||||||
}
|
}
|
||||||
// v0.42 fix #2 (Claude+Gemini HIGH): default to $PWD ONLY. Was: $PWD +
|
|
||||||
// $HOME blanket — too permissive, agent could overwrite ~/.claude/hooks/
|
|
||||||
// or ~/.zshrc and self-neuter the safety layer. Operators who need
|
|
||||||
// broader access opt in via KEI_ALLOWED_ROOTS=":" -separated abs paths.
|
|
||||||
let mut roots = Vec::new();
|
let mut roots = Vec::new();
|
||||||
if let Ok(cwd) = std::env::current_dir() {
|
if let Ok(cwd) = std::env::current_dir() {
|
||||||
roots.push(format!("{}/", cwd.display()));
|
if let Some(r) = canon_with_slash(&cwd.display().to_string()) {
|
||||||
|
roots.push(r);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
roots
|
roots
|
||||||
}
|
}
|
||||||
|
|
@ -472,9 +638,9 @@ async fn run_chain(tool: &str, hook_input: &Value) -> Result<(), String> {
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.kill_on_drop(true);
|
.kill_on_drop(true);
|
||||||
// v0.42 fix #5: put hook child in its own process group so timeout
|
|
||||||
// can killpg the whole tree (was: kill_on_drop = immediate child only).
|
|
||||||
set_process_group(&mut child_cmd);
|
set_process_group(&mut child_cmd);
|
||||||
|
// v0.44 fix #4: same env-isolation for hook subprocess.
|
||||||
|
apply_safe_env(&mut child_cmd);
|
||||||
|
|
||||||
let mut child = child_cmd
|
let mut child = child_cmd
|
||||||
.spawn()
|
.spawn()
|
||||||
|
|
|
||||||
2
bin/kei
2
bin/kei
|
|
@ -230,7 +230,7 @@ ${C1} ██╔═██╗ ██╔══╝ ██║╚════█
|
||||||
${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0}
|
${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0}
|
||||||
${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0}
|
${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0}
|
||||||
|
|
||||||
${C2} KeiSeiKit · substrate v0.42${C0}
|
${C2} KeiSeiKit · substrate v0.44${C0}
|
||||||
${C3} ─────────────────────────────────────${C0}
|
${C3} ─────────────────────────────────────${C0}
|
||||||
primary CLI : ${CV}${PRIMARY}${C0}
|
primary CLI : ${CV}${PRIMARY}${C0}
|
||||||
profile : ${CV}${p}${C0}
|
profile : ${CV}${p}${C0}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"name": "keisei",
|
"name": "keisei",
|
||||||
"displayName": "KeiSei",
|
"displayName": "KeiSei",
|
||||||
"description": "Constructor Pattern multi-LLM agent substrate — 38 agents, 69 skills, 54 hooks, 86 blocks. Cross-CLI policy enforcement (Claude/Grok/Copilot/Agy/Kimi) via kei-mcp + kei_bash/kei_edit/kei_write. Rust primitives via classic ./install.sh.",
|
"description": "Constructor Pattern multi-LLM agent substrate — 38 agents, 69 skills, 54 hooks, 86 blocks. Cross-CLI policy enforcement (Claude/Grok/Copilot/Agy/Kimi) via kei-mcp + kei_bash/kei_edit/kei_write. Rust primitives via classic ./install.sh.",
|
||||||
"version": "0.42.0",
|
"version": "0.44.0",
|
||||||
"homepage": "https://keisei.app",
|
"homepage": "https://keisei.app",
|
||||||
"repository": "https://github.com/KeiSeiLab/KeiSeiKit-1.0.git",
|
"repository": "https://github.com/KeiSeiLab/KeiSeiKit-1.0.git",
|
||||||
"author": {
|
"author": {
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,17 @@ probe_kimi() {
|
||||||
printf '%s' '{"status":"no-curl","note":"curl required for live probe"}'
|
printf '%s' '{"status":"no-curl","note":"curl required for live probe"}'
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
# v0.43-fix #3: feed the bearer token via stdin (--config -), NOT as
|
# v0.44 fix #3 (Gemini HIGH): sanitize MOONSHOT_API_KEY before formatting.
|
||||||
# a curl argv. argv is visible to `ps`/`/proc/<pid>/cmdline` for any
|
# Was: token injected into a curl --config line via printf 'header = "...%s..."';
|
||||||
# local user. Audit found this on critic@claude.
|
# if the token contained a double-quote + newline + 'url = "attacker"',
|
||||||
|
# curl would parse the injected config option and redirect the request.
|
||||||
|
# Now: validate the key matches a known-safe charset; reject otherwise.
|
||||||
|
case "$MOONSHOT_API_KEY" in
|
||||||
|
*[!A-Za-z0-9_.\-]*)
|
||||||
|
printf '%s' '{"status":"probe-failed","note":"MOONSHOT_API_KEY contains unsafe chars; expected [A-Za-z0-9_.-]"}'
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
local resp
|
local resp
|
||||||
resp=$(printf 'header = "Authorization: Bearer %s"\n' "$MOONSHOT_API_KEY" \
|
resp=$(printf 'header = "Authorization: Bearer %s"\n' "$MOONSHOT_API_KEY" \
|
||||||
| curl -sS --max-time 5 --config - \
|
| curl -sS --max-time 5 --config - \
|
||||||
|
|
@ -143,9 +151,19 @@ else
|
||||||
rm -f "$TMP" 2>/dev/null
|
rm -f "$TMP" 2>/dev/null
|
||||||
echo "kei-limits: cache refresh failed — keeping previous cache" >&2
|
echo "kei-limits: cache refresh failed — keeping previous cache" >&2
|
||||||
if [ ! -f "$CACHE" ]; then
|
if [ ! -f "$CACHE" ]; then
|
||||||
# No prior cache + assembly failed: write a minimal marker so consumers
|
# v0.44 fix #9 (Claude MED): failure-fallback must carry the SAME schema
|
||||||
# don't see a missing file as their failure mode.
|
# as the success cache (ts + 5 per-CLI keys). Was: emitted only {ts,
|
||||||
printf '%s\n' '{"ts":"","status":"assembly-failed"}' > "$CACHE"
|
# status} which broke pet's .kimi.available_balance_usd read and the
|
||||||
|
# script's own per-CLI render loop. Now: full shape, all 5 marked
|
||||||
|
# status="assembly-failed".
|
||||||
|
jq -n '{ts:"",
|
||||||
|
claude:{status:"assembly-failed",note:"see logs"},
|
||||||
|
grok:{status:"assembly-failed",note:"see logs"},
|
||||||
|
agy:{status:"assembly-failed",note:"see logs"},
|
||||||
|
copilot:{status:"assembly-failed",note:"see logs"},
|
||||||
|
kimi:{status:"assembly-failed",note:"see logs"}}' \
|
||||||
|
> "$CACHE" 2>/dev/null \
|
||||||
|
|| printf '%s\n' '{"ts":"","claude":{"status":"assembly-failed"},"grok":{"status":"assembly-failed"},"agy":{"status":"assembly-failed"},"copilot":{"status":"assembly-failed"},"kimi":{"status":"assembly-failed"}}' > "$CACHE"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue