Filesystem Guards
Four guards in chio-guards protect the filesystem surface: a forbidden-path denylist, an allowlist counterpart, a secret detector that scans write content, and a patch analyzer that bounds diff size and forbids backdoor patterns. All four operate over a normalized path computed from the request arguments by crate::action::extract_action.
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.
ForbiddenPathGuard
Blocks file access to sensitive paths via a glob denylist. Source: crates/chio-guards/src/forbidden_path.rs. Guard name: forbidden-path.
Struct
pub struct ForbiddenPathGuard {
patterns: Vec<glob::Pattern>,
exceptions: Vec<glob::Pattern>,
}
impl ForbiddenPathGuard {
pub fn new() -> Self;
pub fn with_patterns(patterns: Vec<String>, exceptions: Vec<String>) -> Self;
pub fn is_forbidden(&self, path: &str) -> bool;
}Default forbidden patterns
Built by default_forbidden_patterns(). These are the actual entries from source, in source order.
// SSH keys
"**/.ssh/**"
"**/id_rsa*"
"**/id_ed25519*"
"**/id_ecdsa*"
// AWS credentials
"**/.aws/**"
// Environment files
"**/.env"
"**/.env.*"
// Git credentials
"**/.git-credentials"
"**/.gitconfig"
// GPG keys
"**/.gnupg/**"
// Kubernetes
"**/.kube/**"
// Docker
"**/.docker/**"
// NPM tokens
"**/.npmrc"
// Password stores
"**/.password-store/**"
"**/pass/**"
// 1Password
"**/.1password/**"
// System paths (Unix)
"/etc/shadow"
"/etc/passwd"
"/etc/sudoers"
// Windows credential stores
"**/AppData/Roaming/Microsoft/Credentials/**"
"**/AppData/Local/Microsoft/Credentials/**"
"**/AppData/Roaming/Microsoft/Vault/**"
"**/NTUSER.DAT"
"**/NTUSER.DAT.*"
"**/Windows/System32/config/SAM"
"**/Windows/System32/config/SECURITY"
"**/Windows/System32/config/SYSTEM"
"**/*.reg"
"**/AppData/Roaming/Microsoft/SystemCertificates/**"
"**/WindowsPowerShell/profile.ps1"
"**/PowerShell/profile.ps1"Windows globs are kept on Unix; they simply 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
- Invalid glob in user input :: silently dropped by
filter_map(|p| Pattern::new(p).ok()). - 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/chio-guards/src/path_allowlist.rs. Guard name: path-allowlist.
Struct
pub struct PathAllowlistConfig {
pub enabled: bool,
pub file_access_allow: Vec<String>,
pub file_write_allow: Vec<String>,
pub patch_allow: Vec<String>,
}
pub struct PathAllowlistGuard {
enabled: bool,
file_access_allow: Vec<glob::Pattern>,
file_write_allow: Vec<glob::Pattern>,
patch_allow: Vec<glob::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).
// Verified from path_allowlist.rs evaluate():
if let Some(session_roots) = ctx.session_filesystem_roots {
if !self.matches_session_roots(path, session_roots) {
return Ok(Verdict::Deny);
}
}
if !self.enabled {
return Ok(Verdict::Allow);
}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/chio-guards/src/secret_leak.rs. Guard name: secret-leak.
Struct
pub struct SecretLeakConfig {
pub enabled: bool,
pub skip_paths: Vec<String>,
pub custom_patterns: Vec<CustomSecretPattern>,
}
pub struct CustomSecretPattern {
pub name: String,
pub pattern: String,
}
pub struct SecretLeakGuard { /* private */ }
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():
skip_paths: vec![
"**/test/**",
"**/tests/**",
"**/*_test.*",
"**/*.test.*",
]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(in practice this should never fire because the patterns are tested). SecretLeakGuard::new()panics if the default config fails to compile. This is treated as a source-level invariant.
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/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
// Disable security features
r"(?i)disable[ _\-]?(security|auth|ssl|tls)"
r"(?i)skip[ _\-]?(verify|validation|check)"
// Dangerous operations
r"(?i)rm\s+-rf\s+/"
r"(?i)chmod\s+777"
r"(?i)eval\s*\("
r"(?i)exec\s*\("
// Backdoor indicators
r"(?i)reverse[_\-]?shell"
r"(?i)bind[_\-]?shell"
r"base64[_\-]?decode.*exec"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 ::PatchIntegrityConfigError::InvalidForbiddenPatternat construction.PatchIntegrityGuard::new()panics if the default set fails to compile. - 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(). Cheap glob checks run first; the secret scanner and patch analyzer run later because their cost grows with content size.
use chio_guards::{
ForbiddenPathGuard, PathAllowlistGuard, SecretLeakGuard, PatchIntegrityGuard,
};
let mut pipeline = chio_guards::GuardPipeline::new();
pipeline.add(Box::new(ForbiddenPathGuard::new()));
pipeline.add(Box::new(PathAllowlistGuard::new())); // disabled by default
pipeline.add(Box::new(SecretLeakGuard::new()));
pipeline.add(Box::new(PatchIntegrityGuard::new()));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.