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
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions config/security_policy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,13 @@ rules:
risk: LOW
reason: "临时区代码和项目文件,允许 AI 修改"

# Secret detection patterns (optional).
# Uncomment and customize to add your own patterns.
# Built-in patterns cover: OpenAI, Anthropic, AWS, Google, GitHub, Stripe,
# Slack, Fireworks API keys; JWT tokens; URL-embedded passwords.
#
# secret_patterns:
# - name: "My Internal Token"
# pattern: "int_[a-zA-Z0-9]{32}"
# secret_type: api_key

10 changes: 10 additions & 0 deletions crates/aish-i18n/locales/en-US.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,16 @@ shell:
alternative:
generic: "Please manually review this command and verify impact before execution."
etc_manual: "If you must modify files under /etc, prefer a manual change process with backups/change management."
secret:
title: "Security Warning"
detected: "Detected sensitive data in input:\n {reasons}\n\nChoose how to proceed:"
redact: "Replace with env vars (safe)"
allow: "Send plaintext to AI"
abort: "Cancel"
aborted: "Aborted: sensitive data detected."
redacted: "Replaced {count} secret(s) with env var placeholders (stored in memory only, not sent to AI)"
restored: "(Restored {count} env var(s) from in-memory vault, not sent to AI)"
option_esc: "[Enter to confirm, ↑↓ to select, Esc to cancel]"
options_header: "Options"
option:
approve: "Approve"
Expand Down
10 changes: 10 additions & 0 deletions crates/aish-i18n/locales/zh-CN.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,16 @@ shell:
alternative:
generic: "请人工复核该命令并在确认影响后再执行。"
etc_manual: "如需修改 /etc 下文件,建议采用人工变更并配合变更管理或备份策略。"
secret:
title: "安全警告"
detected: "检测到输入中包含敏感数据:\n {reasons}\n\n请选择处理方式:"
redact: "替换为环境变量发送 (安全)"
allow: "明文发送给 AI"
abort: "取消"
aborted: "已取消:检测到敏感数据。"
redacted: "已将 {count} 个敏感数据替换为环境变量占位符(仅保存在内存中,未发送给 AI)"
restored: "(已还原 {count} 个环境变量,仅保存在内存中,未发送给 AI)"
option_esc: "[Enter 确认, ↑↓ 选择, Esc 取消]"
options_header: "选项"
option:
approve: "允许"
Expand Down
1 change: 1 addition & 0 deletions crates/aish-pty/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ edition.workspace = true
aish-core.workspace = true
aish-hosts.workspace = true
aish-i18n.workspace = true
aish-security.workspace = true
nix.workspace = true
tokio.workspace = true
serde.workspace = true
Expand Down
8 changes: 8 additions & 0 deletions crates/aish-pty/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ pub mod session_interceptor;
pub mod state_capture;
pub mod types;

/// Result returned by the SSH secret-check closure.
pub struct SshSecretCheckResult {
/// Formatted warning message (title + detected secrets).
pub warning: String,
/// Detected secret matches for vault redaction.
pub detected_secrets: Vec<aish_security::secret::SecretMatch>,
}

pub use command_state::CommandState;
pub use control::{decode_control_chunk, encode_control_event, BackendControlEvent};
pub use executor::PtyExecutor;
Expand Down
279 changes: 272 additions & 7 deletions crates/aish-pty/src/persistent.rs

Large diffs are not rendered by default.

16 changes: 12 additions & 4 deletions crates/aish-pty/src/session_interceptor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,9 +323,17 @@ impl SessionInterceptor {

/// Run the AI callback. The callback returns an AiResponse containing
/// an optional command and display text (or None on error).
pub fn call_ai(&self, question: String) -> Option<AiResponse> {
pub fn call_ai(
&self,
question: String,
secret_vault: Option<&std::sync::Arc<std::sync::Mutex<aish_security::secret::SecretVault>>>,
) -> Option<AiResponse> {
self.ai_callback.as_ref().and_then(|cb| {
let recent = self.recent_output(4000);
let mut recent = self.recent_output(4000);
if let Some(vault) = secret_vault {
let (redacted, _) = vault.lock().unwrap().redact_output(&recent);
recent = redacted;
}
cb(AiQuery {
question,
recent_output: recent,
Expand Down Expand Up @@ -564,7 +572,7 @@ mod tests {
let mut ic = SessionInterceptor::new(Some(noop_callback()));
ic.feed_stdin(b';');
ic.feed_stdin(b'\r');
let resp = ic.call_ai("test".to_string());
let resp = ic.call_ai("test".to_string(), None);
assert!(resp.is_some());
let r = resp.unwrap();
assert_eq!(r.command, Some("echo test".to_string()));
Expand All @@ -573,7 +581,7 @@ mod tests {
#[test]
fn test_call_ai_returns_none() {
let ic = SessionInterceptor::new(Some(noop_callback_no_cmd()));
let cmd = ic.call_ai("test".to_string());
let cmd = ic.call_ai("test".to_string(), None);
assert!(cmd.is_none());
}

Expand Down
2 changes: 2 additions & 0 deletions crates/aish-security/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
thiserror.workspace = true
regex.workspace = true
tracing.workspace = true

[dev-dependencies]
tempfile.workspace = true
24 changes: 24 additions & 0 deletions crates/aish-security/src/decision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ pub struct SecurityAnalysis {
pub sandbox_off_action: Option<SandboxOffAction>,
#[serde(default)]
pub sandbox: SandboxStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detected_secrets: Option<Vec<crate::secret::SecretMatch>>,
}

impl Default for SecurityAnalysis {
Expand All @@ -113,6 +115,7 @@ impl Default for SecurityAnalysis {
matched_paths: Vec::new(),
sandbox_off_action: None,
sandbox: SandboxStatus::default(),
detected_secrets: None,
}
}
}
Expand Down Expand Up @@ -196,4 +199,25 @@ mod tests {
assert_eq!(SandboxStatus::default().error, None);
assert_eq!(SandboxStatus::default().exit_code, None);
}

#[test]
fn security_analysis_default_has_no_detected_secrets() {
let analysis = SecurityAnalysis::default();
assert!(analysis.detected_secrets.is_none());
}

#[test]
fn security_analysis_serializes_detected_secrets_roundtrip() {
let mut analysis = SecurityAnalysis::default();
analysis.detected_secrets = Some(vec![crate::secret::SecretMatch {
pattern_name: "Test Key".to_string(),
start: 5,
end: 20,
secret_type: crate::secret::SecretType::ApiKey,
}]);
let json = serde_json::to_string(&analysis).unwrap();
let deserialized: SecurityAnalysis = serde_json::from_str(&json).unwrap();
assert!(deserialized.detected_secrets.is_some());
assert_eq!(deserialized.detected_secrets.unwrap().len(), 1);
}
}
1 change: 1 addition & 0 deletions crates/aish-security/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod manager;
pub mod policy;
pub mod risk;
mod sandbox;
pub mod secret;
pub mod sudo;
pub mod types;

Expand Down
62 changes: 62 additions & 0 deletions crates/aish-security/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use crate::sandbox::degraded::{analyze_sandbox_degraded, SandboxDegradedDetails}
use crate::sandbox::error::{SandboxError, SandboxReason};
use crate::sandbox::ipc::client::SandboxRunner;
use crate::sandbox::types::SandboxRunRequest;
use crate::secret::SecretScanner;
use crate::SandboxClient;

const DEFAULT_SANDBOX_SOCKET_PATH: &str = "/run/aish/sandbox.sock";
Expand Down Expand Up @@ -87,6 +88,7 @@ pub struct SecurityManager {
policy: SecurityPolicy,
fallback_engine: FallbackRuleEngine,
sandbox_runner: Option<Arc<dyn SandboxRunner>>,
secret_scanner: SecretScanner,
}

impl SecurityManager {
Expand All @@ -95,17 +97,23 @@ impl SecurityManager {
let sandbox_runner = policy.enable_sandbox.then(|| {
Arc::new(SandboxClient::new(DEFAULT_SANDBOX_SOCKET_PATH)) as Arc<dyn SandboxRunner>
});
let secret_scanner = SecretScanner::new(&policy.secret_patterns);
Self {
policy,
fallback_engine,
sandbox_runner,
secret_scanner,
}
}

pub fn policy(&self) -> &SecurityPolicy {
&self.policy
}

pub fn secret_scanner(&self) -> &SecretScanner {
&self.secret_scanner
}

pub fn with_sandbox_client(mut self, client: SandboxClient) -> Self {
self.sandbox_runner = Some(Arc::new(client));
self
Expand Down Expand Up @@ -159,6 +167,23 @@ impl SecurityManager {
decision_from_risk(level, analysis)
}

/// Check AI input text for sensitive data.
/// Returns `Confirm` if secrets are detected, `Allow` otherwise.
pub fn check_ai_input(&self, input: &str) -> SecurityDecision {
let secrets = self.secret_scanner.scan(input);
if secrets.is_empty() {
return SecurityDecision::allow(RiskLevel::Low, SecurityAnalysis::default());
}

let analysis = SecurityAnalysis {
risk_level: RiskLevel::Medium,
reasons: secrets.iter().map(|s| s.format_reason()).collect(),
detected_secrets: Some(secrets),
..SecurityAnalysis::default()
};
SecurityDecision::confirm(RiskLevel::Medium, analysis)
}

fn analyze_with_sandbox(&self, command: &str, request: &SecurityRequest) -> SecurityAnalysis {
let cwd = request
.cwd()
Expand Down Expand Up @@ -226,6 +251,7 @@ impl std::fmt::Debug for SecurityManager {
.field("policy", &self.policy)
.field("fallback_engine", &self.fallback_engine)
.field("sandbox_runner", &self.sandbox_runner.is_some())
.field("secret_scanner", &"<SecretScanner>")
.finish()
}
}
Expand Down Expand Up @@ -577,4 +603,40 @@ mod tests {
Some("/repo")
);
}

#[test]
fn check_ai_input_allows_clean_text() {
let manager = SecurityManager::new(SecurityPolicy::default());
let decision = manager.check_ai_input("list files in current directory");
assert!(decision.allow);
assert!(!decision.require_confirmation);
}

#[test]
fn check_ai_input_confirms_on_secret() {
let manager = SecurityManager::new(SecurityPolicy::default());
let decision = manager.check_ai_input(
"use key sk-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456",
);
assert!(decision.allow);
assert!(decision.require_confirmation);
let secrets = decision.analysis.detected_secrets.unwrap();
assert!(!secrets.is_empty());
assert_eq!(secrets[0].secret_type, crate::secret::SecretType::ApiKey);
}

#[test]
fn check_ai_input_with_custom_pattern() {
let mut policy = SecurityPolicy::default();
policy.secret_patterns = vec![crate::secret::CustomPattern {
name: "MyToken".to_string(),
pattern: r"mytoken_[a-z]{8}".to_string(),
secret_type: crate::secret::SecretType::Token,
}];
let manager = SecurityManager::new(policy);
let decision = manager.check_ai_input("here is mytoken_abcdefgh ok");
assert!(decision.require_confirmation);
let secrets = decision.analysis.detected_secrets.unwrap();
assert_eq!(secrets[0].pattern_name, "MyToken");
}
}
30 changes: 29 additions & 1 deletion crates/aish-security/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ pub struct SecurityPolicy {
pub invalid_fallback_rules: Vec<InvalidFallbackRule>,
#[serde(default)]
pub validation_issues: Vec<ValidationIssue>,
#[serde(default)]
pub secret_patterns: Vec<crate::secret::CustomPattern>,
}

impl Default for SecurityPolicy {
Expand All @@ -80,6 +82,7 @@ impl Default for SecurityPolicy {
rules: default_rules(),
invalid_fallback_rules: Vec::new(),
validation_issues: Vec::new(),
secret_patterns: Vec::new(),
}
}
}
Expand Down Expand Up @@ -428,7 +431,7 @@ pub fn load_policy(config_path: Option<&Path>) -> SecurityPolicy {
.filter(|item| mapping_get(item, "path").is_some())
.collect();

let (invalid_fallback_rules, validation_issues) = parse_invalid_fallback_rules(&v2_items);
let (invalid_fallback_rules, mut validation_issues) = parse_invalid_fallback_rules(&v2_items);

let valid_items: Vec<&Mapping> = v2_items
.iter()
Expand All @@ -448,6 +451,30 @@ pub fn load_policy(config_path: Option<&Path>) -> SecurityPolicy {
let mut rules = default_rules();
rules.extend(parse_v2_rules(&valid_items));

let secret_patterns: Vec<crate::secret::CustomPattern> = root
.and_then(|m| mapping_get(m, "secret_patterns"))
.and_then(Value::as_sequence)
.map(|seq| {
seq.iter()
.filter_map(|v| {
let yaml_str = serde_yaml::to_string(v).unwrap_or_default();
match serde_yaml::from_value(v.clone()) {
Ok(pattern) => Some(pattern),
Err(e) => {
validation_issues.push(ValidationIssue {
rule_id: None,
field: "secret_patterns".to_string(),
value: Some(yaml_str.trim().to_string()),
message: Some(format!("Invalid custom pattern: {e}")),
});
None
}
}
})
.collect()
Comment thread
jexShain marked this conversation as resolved.
})
.unwrap_or_default();

SecurityPolicy {
enable_sandbox,
sandbox_off_action,
Expand All @@ -458,6 +485,7 @@ pub fn load_policy(config_path: Option<&Path>) -> SecurityPolicy {
rules,
invalid_fallback_rules,
validation_issues,
secret_patterns,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/aish-security/src/sandbox/degraded.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ fn analysis_from_fallback(
matched_paths: assessment.matched_paths.clone(),
sandbox_off_action: Some(policy.sandbox_off_action),
sandbox: sandbox_status(reason, details),
detected_secrets: None,
}
}

Expand Down
7 changes: 7 additions & 0 deletions crates/aish-security/src/secret/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
mod patterns;
mod scanner;
mod vault;

pub use patterns::{CustomPattern, SecretMatch, SecretPattern, SecretType};
pub use scanner::SecretScanner;
pub use vault::SecretVault;
Loading
Loading