PlatformSystem Access Guards
Kernel
Filesystem Guards
Four guards control filesystem access: a forbidden-path denylist, a path allowlist, a write-content secret detector, and a patch analyzer.
Verdict shape
Verdict::Allow / Verdict::Deny / Verdict::PendingApproval. The guards on this page emit only Allow and Deny. None of them carry a payload on the verdict itself; reasons land on the receipt evidence block.Path normalization
Every filesystem guard runs the input through three normalizers from crate::path_normalization:
normalize_path_for_policy:: lexical normalization (strips.and.., collapses separators, handles Windows backslashes).normalize_path_for_policy_with_fs:: filesystem-resolved normalization (resolves symlinks via the OS).normalize_path_for_policy_lexical_absolute:: absolute lexical normalization without filesystem access.
When the resolved path differs from the lexical path (the symlink-escape case), the guards require the resolved path to match policy. This closes the lexical-bypass hole where a symlink inside an allowed directory points at a denied target. The resolved path must satisfy the policy.
ForbiddenPathGuard
Blocks file access to sensitive paths via a glob denylist. Source: crates/guards/chio-guards/src/forbidden_path.rs. Guard name: forbidden-path.
Struct
pub struct ForbiddenPathGuard {
patterns: Vec<Pattern>,
exceptions: Vec<Pattern>,
}
// ...
impl ForbiddenPathGuard {
pub fn new() -> Self;
pub fn with_patterns(
patterns: Vec<String>,
exceptions: Vec<String>,
) -> Result<Self, ForbiddenPathConfigError>;
pub fn is_forbidden(&self, path: &str) -> bool;
}Pattern is glob::Pattern: both lists are compiled once at construction, so matching a request costs no parsing.
Default forbidden patterns
Built by default_forbidden_patterns().
fn default_forbidden_patterns() -> Vec<String> {
let mut patterns = vec![
// SSH keys
"**/.ssh/**".to_string(),
"**/id_rsa*".to_string(),
"**/id_ed25519*".to_string(),
"**/id_ecdsa*".to_string(),
// AWS credentials
"**/.aws/**".to_string(),
// Environment files
"**/.env".to_string(),
"**/.env.*".to_string(),
// Git credentials
"**/.git-credentials".to_string(),
"**/.gitconfig".to_string(),
// GPG keys
"**/.gnupg/**".to_string(),
// Kubernetes
"**/.kube/**".to_string(),
// Docker
"**/.docker/**".to_string(),
// NPM tokens
"**/.npmrc".to_string(),
// Password stores
"**/.password-store/**".to_string(),
"**/pass/**".to_string(),
// 1Password
"**/.1password/**".to_string(),
// System paths (Unix)
"/etc/shadow".to_string(),
"/etc/passwd".to_string(),
"/etc/sudoers".to_string(),
];
// Windows paths -- on non-Windows these globs never match.
patterns.extend([
"**/AppData/Roaming/Microsoft/Credentials/**".to_string(),
"**/AppData/Local/Microsoft/Credentials/**".to_string(),
"**/AppData/Roaming/Microsoft/Vault/**".to_string(),
"**/NTUSER.DAT".to_string(),
"**/NTUSER.DAT.*".to_string(),
"**/Windows/System32/config/SAM".to_string(),
"**/Windows/System32/config/SECURITY".to_string(),
"**/Windows/System32/config/SYSTEM".to_string(),
"**/*.reg".to_string(),
"**/AppData/Roaming/Microsoft/SystemCertificates/**".to_string(),
"**/WindowsPowerShell/profile.ps1".to_string(),
"**/PowerShell/profile.ps1".to_string(),
]);
patterns
}Windows globs are kept on Unix; they never match. There is no platform-conditional compilation on the pattern set.
Configuration
| Knob | Type | Default | Purpose |
|---|---|---|---|
patterns | Vec<String> | 31 globs (above) | Deny patterns. Path matches any ⇒ deny. |
exceptions | Vec<String> | empty | Globs that override a deny. Checked first. |
Algorithm
- Compute the lexical, resolved (FS-aware), and absolute-lexical paths.
- If the resolved path differs from the lexical target (symlink case), exception matching uses only the resolved path. Otherwise either form can match an exception.
- For each forbidden pattern, deny if either the resolved or lexical form matches. An exception match short-circuits to allow.
Failure modes & evidence
- Operator-supplied globs go through
with_patterns(patterns, exceptions), which fails closed: an invalid glob returnsForbiddenPathConfigError::InvalidPatternand the guard does not construct, so a typo in policy cannot silently disable a block. Thefilter_map(|p| Pattern::new(p).ok())path applies only tonew(), which compiles the hardcoded default patterns. Those are all valid globs, so it never drops one. - Non-filesystem actions (no
FileAccess,FileWrite, orPatchin the action enum): guard returnsVerdict::Allow. - Performance: O(P) glob matches per call, where P is the pattern count. The lexical path computation is allocation-light; the FS-resolved variant performs a single
canonicalizesyscall on Unix.
guards:
forbidden_path:
patterns:
- "**/.ssh/**"
- "**/.env"
- "/etc/shadow"
exceptions:
- "**/fixtures/.env.example"PathAllowlistGuard
The dual of ForbiddenPathGuard: deny-by-default within an explicit allowlist. Source: crates/guards/chio-guards/src/path_allowlist.rs. Guard name: path-allowlist.
Struct
pub struct PathAllowlistConfig {
/// Enable/disable this guard.
pub enabled: bool,
/// Allowed globs for file access operations.
pub file_access_allow: Vec<String>,
/// Allowed globs for file write operations.
pub file_write_allow: Vec<String>,
/// Allowed globs for patch operations (falls back to `file_write_allow` when empty).
pub patch_allow: Vec<String>,
}The guard holds the compiled form of the same three lists.
pub struct PathAllowlistGuard {
enabled: bool,
file_access_allow: Vec<Pattern>,
file_write_allow: Vec<Pattern>,
patch_allow: Vec<Pattern>,
}Configuration
| Knob | Type | Default | Purpose |
|---|---|---|---|
enabled | bool | false | Disabled until operator opts in. When off, allows all paths (subject to session-roots check). |
file_access_allow | Vec<String> | empty | Globs that allow read. |
file_write_allow | Vec<String> | empty | Globs that allow write. |
patch_allow | Vec<String> | empty | Globs that allow patch. When empty, falls back to file_write_allow. |
Session filesystem roots
When the kernel passes session_filesystem_roots on the GuardContext, the guard also enforces that the path falls within at least one root. This check runs before the enabled flag is consulted, so a session-root violation denies even with the guard disabled. An empty root set means deny (fail-closed).
fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
if !self.enabled && ctx.session_filesystem_roots.is_none() {
return Ok(GuardDecision::allow());
}
let action = match extract_action_checked(&ctx.request.tool_name, &ctx.request.arguments) {
Ok(action) => action,
Err(_) => return Ok(GuardDecision::deny(Vec::new())),
};
let Some(path) = action.filesystem_path() else {
return Ok(GuardDecision::allow());
};
if let Some(session_roots) = ctx.session_filesystem_roots {
if !self.matches_session_roots(path, session_roots) {
return Ok(GuardDecision::deny(Vec::new()));
}
}
if !self.enabled {
return Ok(GuardDecision::allow());
}
let allowed = match &action {
ToolAction::FileAccess(path) => self.is_file_access_allowed(path),
ToolAction::FileWrite(path, _) => self.is_file_write_allowed(path),
ToolAction::Patch(path, _) => self.is_patch_allowed(path),
// Fail closed: any path-bearing action this allowlist cannot
// classify is denied, never silently allowed.
_ => false,
};
if allowed {
Ok(GuardDecision::allow())
} else {
Ok(GuardDecision::deny(Vec::new()))
}
}Symlink-escape behavior
When the FS-resolved path differs from the lexical target (a symlink traversal), the guard requires the resolved path to match the allowlist. This prevents a symlink inside an allowed directory from granting access to a target outside it.
SecretLeakGuard
Scans FileWrite and Patch content for credential leaks. Source: crates/guards/chio-guards/src/secret_leak.rs. Guard name: secret-leak.
Struct
pub struct SecretLeakConfig {
/// Enable/disable this guard.
pub enabled: bool,
/// File path patterns to skip (e.g. test fixtures).
pub skip_paths: Vec<String>,
/// Additional operator-defined secret patterns.
pub custom_patterns: Vec<CustomSecretPattern>,
}An operator-supplied pattern is a name and a regex string.
pub struct CustomSecretPattern {
pub name: String,
pub pattern: String,
}The guard keeps the compiled form of both pattern sets and of the skip list.
pub struct SecretLeakGuard {
enabled: bool,
patterns: Vec<CompiledPattern>,
skip_paths: Vec<glob::Pattern>,
}A hit is reported as a SecretMatch: where the value sat in the content, and a masked rendering of it rather than the value itself.
pub struct SecretMatch {
pub pattern_name: String,
pub offset: usize,
pub length: usize,
pub redacted: String,
}Default detectors
Names and regex sources verified from default_patterns().
| Pattern name | Regex |
|---|---|
aws_access_key | AKIA[0-9A-Z]{16} |
aws_secret_key | case-insensitive aws_secret_access_key assignment, 40 base64 chars |
github_token | gh[ps]_[A-Za-z0-9]{36} |
github_pat | github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59} |
openai_key | sk-[A-Za-z0-9]{48} |
openai_project_key | sk-proj-[A-Za-z0-9]{48,} |
anthropic_key | sk-ant-[A-Za-z0-9\\-]{95} |
anthropic_api03_key | sk-ant-api03-[A-Za-z0-9_\\-]{93} |
private_key | PEM -----BEGIN (RSA )?PRIVATE KEY----- |
npm_token | npm_[A-Za-z0-9]{36} |
slack_token | xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]* |
stripe_secret_key | sk_live_[A-Za-z0-9]{24,} |
stripe_restricted_key | rk_live_[A-Za-z0-9]{24,} |
gcp_service_account | JSON "type": "service_account" |
azure_key_vault_token | case-insensitive Azure KV assignment, 32+ base64 chars |
gitlab_pat | glpat-[A-Za-z0-9_\\-]{20,} |
generic_api_key | case-insensitive api_key= with 32+ alphanum |
generic_secret | case-insensitive secret/password/passwd/pwd= with 8+ chars |
Default skip paths
From SecretLeakConfig::default():
impl Default for SecretLeakConfig {
fn default() -> Self {
Self {
enabled: true,
skip_paths: vec![
"**/test/**".to_string(),
"**/tests/**".to_string(),
"**/*_test.*".to_string(),
"**/*.test.*".to_string(),
],
custom_patterns: Vec::new(),
}
}
}Matching paths bypass scanning entirely. The defaults exempt test fixtures so committed example tokens do not block writes.
Configuration
| Knob | Type | Default | Purpose |
|---|---|---|---|
enabled | bool | true | Master switch. |
skip_paths | Vec<String> | 4 globs (above) | Paths to skip scanning entirely. |
custom_patterns | Vec<CustomSecretPattern> | empty | Operator-defined detectors. Compiled at config-load time. |
Algorithm
- Read action. Only
FileWrite(path, content)andPatch(path, diff)proceed. Other actions returnVerdict::Allow. - Skip-path check. If any skip glob matches the path, return
Allow. - UTF-8 decode the content. Binary content (failed decode) returns empty matches and the call is allowed.
- Run every compiled detector regex against the text via
find_iter. Any match denies.
Failure modes
- Invalid
custom_patternsregex ::SecretLeakConfigError::InvalidCustomPatternatwith_config. The guard will not construct. - Built-in regex compile failure ::
SecretLeakConfigError::InvalidBuiltInPattern(the built-in patterns are covered by tests). SecretLeakGuard::new()does not panic if the default config fails to compile. Throughbuild_default_or_fail_closed()it falls back to anunavailable_fail_closed()guard whose sole pattern is[\s\S]+. That pattern matches all content, so every write and patch is blocked until the config is repaired.
Redaction format
Detected secrets are reported via SecretMatch.redacted: the first 4 and last 4 characters of the match are kept, the middle is replaced with *. Matches shorter than 8 chars become all asterisks.
PatchIntegrityGuard
Validates unified-diff payloads before they are applied. Source: crates/guards/chio-guards/src/patch_integrity.rs. Guard name: patch-integrity.
Struct
pub struct PatchIntegrityConfig {
pub enabled: bool,
pub max_additions: usize,
pub max_deletions: usize,
pub forbidden_patterns: Vec<String>,
pub require_balance: bool,
pub max_imbalance_ratio: f64,
}
pub struct PatchAnalysis {
pub additions: usize,
pub deletions: usize,
pub imbalance_ratio: f64,
pub forbidden_matches: Vec<ForbiddenMatch>,
pub exceeds_max_additions: bool,
pub exceeds_max_deletions: bool,
pub exceeds_imbalance: bool,
}Defaults
| Knob | Type | Default | Purpose |
|---|---|---|---|
enabled | bool | true | Master switch. |
max_additions | usize | 1000 | Hard ceiling on added lines per patch. |
max_deletions | usize | 500 | Hard ceiling on removed lines per patch. |
forbidden_patterns | Vec<String> | 9 regexes (below) | Patterns that, if matched on an added line, deny the patch. |
require_balance | bool | false | When on, deny if additions/deletions exceed max_imbalance_ratio. |
max_imbalance_ratio | f64 | 10.0 | Ratio cutoff (only applied when require_balance). |
Default forbidden patterns
fn default_forbidden_patterns() -> Vec<String> {
vec![
// Disable security features
r"(?i)disable[ _\-]?(security|auth|ssl|tls)".to_string(),
r"(?i)skip[ _\-]?(verify|validation|check)".to_string(),
// Dangerous operations
r"(?i)rm\s+-rf\s+/".to_string(),
r"(?i)chmod\s+777".to_string(),
r"(?i)eval\s*\(".to_string(),
r"(?i)exec\s*\(".to_string(),
// Backdoor indicators
r"(?i)reverse[_\-]?shell".to_string(),
r"(?i)bind[_\-]?shell".to_string(),
r"base64[_\-]?decode.*exec".to_string(),
]
}Algorithm
- Iterate diff lines. Lines starting with
+(excluding+++headers) incrementadditions. Lines starting with-(excluding---) incrementdeletions. - For each added line, run every forbidden regex. Each hit appends a
ForbiddenMatch. - Compute
imbalance_ratio = additions / deletions(orf64::INFINITYwhen deletions are zero and additions are not). - Deny if any forbidden match was found, or any threshold was exceeded.
Failure modes
- Invalid
forbidden_patternsregex supplied viawith_config::PatchIntegrityConfigError::InvalidForbiddenPatternat construction, and the guard does not build.PatchIntegrityGuard::new()does not panic if the default set fails to compile: throughbuild_default_or_fail_closed()it falls back to an enabled guard with an emptyforbidden_regexeslist. - Non-Patch actions return
Verdict::Allow. The guard does not look at file writes; that isSecretLeakGuard's job.
Composition in the default pipeline
All four guards land in the default pipeline built by GuardPipeline::default_pipeline(), but they are not adjacent. That constructor registers seven guards, and three of them come from other domains: ShellCommandGuard, EgressAllowlistGuard, and McpToolGuard. Those three are interleaved between the filesystem guards. The full registration order, from source:
pub fn default_pipeline() -> Self {
let mut pipeline = Self::new();
pipeline.add(Box::new(crate::ForbiddenPathGuard::new()));
pipeline.add(Box::new(crate::ShellCommandGuard::new()));
pipeline.add(Box::new(crate::EgressAllowlistGuard::new()));
pipeline.add(Box::new(crate::PathAllowlistGuard::new()));
pipeline.add(Box::new(crate::McpToolGuard::new()));
pipeline.add(Box::new(crate::SecretLeakGuard::new()));
pipeline.add(Box::new(crate::PatchIntegrityGuard::new()));
pipeline
}PathAllowlistGuard::new() starts disabled, so its registration narrows nothing until policy turns it on.
Cheap glob checks run first; the secret scanner and patch analyzer run later because their cost grows with content size. See Default Pipeline for the non-filesystem guards and the second default set, default_runtime_guard_profile().
PathAllowlistGuard is disabled by default
PathAllowlistGuard::new() constructs with enabled = false and empty allowlists. Until you supply a config via with_config, this guard does not enforce. The session-roots check still runs when the kernel provides session_filesystem_roots.Next steps
- Network Guards :: SSRF and egress controls.
- Shell & Code Guards :: shell command and code execution checks.
- Default Pipeline :: full registration order.