Chio/Docs
LOGIN · JOIN

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

The kernel verdict is 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

crates/guards/chio-guards/src/forbidden_path.rsrust
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().

crates/guards/chio-guards/src/forbidden_path.rs15-66rust
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

KnobTypeDefaultPurpose
patternsVec<String>31 globs (above)Deny patterns. Path matches any ⇒ deny.
exceptionsVec<String>emptyGlobs that override a deny. Checked first.

Algorithm

  1. Compute the lexical, resolved (FS-aware), and absolute-lexical paths.
  2. 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.
  3. 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 returns ForbiddenPathConfigError::InvalidPattern and the guard does not construct, so a typo in policy cannot silently disable a block. The filter_map(|p| Pattern::new(p).ok()) path applies only to new(), which compiles the hardcoded default patterns. Those are all valid globs, so it never drops one.
  • Non-filesystem actions (no FileAccess,FileWrite, or Patch in the action enum): guard returns Verdict::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 canonicalize syscall on Unix.
chio.yamlyaml
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

crates/guards/chio-guards/src/path_allowlist.rs19-28rust
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.

crates/guards/chio-guards/src/path_allowlist.rs35-40rust
pub struct PathAllowlistGuard {
    enabled: bool,
    file_access_allow: Vec<Pattern>,
    file_write_allow: Vec<Pattern>,
    patch_allow: Vec<Pattern>,
}

Configuration

KnobTypeDefaultPurpose
enabledboolfalseDisabled until operator opts in. When off, allows all paths (subject to session-roots check).
file_access_allowVec<String>emptyGlobs that allow read.
file_write_allowVec<String>emptyGlobs that allow write.
patch_allowVec<String>emptyGlobs 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).

crates/guards/chio-guards/src/path_allowlist.rs187-223rust
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()))
    }
}

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

crates/guards/chio-guards/src/secret_leak.rs155-162rust
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.

crates/guards/chio-guards/src/secret_leak.rs26-29rust
pub struct CustomSecretPattern {
    pub name: String,
    pub pattern: String,
}

The guard keeps the compiled form of both pattern sets and of the skip list.

crates/guards/chio-guards/src/secret_leak.rs197-201rust
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.

crates/guards/chio-guards/src/secret_leak.rs117-122rust
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 nameRegex
aws_access_keyAKIA[0-9A-Z]{16}
aws_secret_keycase-insensitive aws_secret_access_key assignment, 40 base64 chars
github_tokengh[ps]_[A-Za-z0-9]{36}
github_patgithub_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59}
openai_keysk-[A-Za-z0-9]{48}
openai_project_keysk-proj-[A-Za-z0-9]{48,}
anthropic_keysk-ant-[A-Za-z0-9\\-]{95}
anthropic_api03_keysk-ant-api03-[A-Za-z0-9_\\-]{93}
private_keyPEM -----BEGIN (RSA )?PRIVATE KEY-----
npm_tokennpm_[A-Za-z0-9]{36}
slack_tokenxox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*
stripe_secret_keysk_live_[A-Za-z0-9]{24,}
stripe_restricted_keyrk_live_[A-Za-z0-9]{24,}
gcp_service_accountJSON "type": "service_account"
azure_key_vault_tokencase-insensitive Azure KV assignment, 32+ base64 chars
gitlab_patglpat-[A-Za-z0-9_\\-]{20,}
generic_api_keycase-insensitive api_key= with 32+ alphanum
generic_secretcase-insensitive secret/password/passwd/pwd= with 8+ chars

Default skip paths

From SecretLeakConfig::default():

crates/guards/chio-guards/src/secret_leak.rs164-177rust
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

KnobTypeDefaultPurpose
enabledbooltrueMaster switch.
skip_pathsVec<String>4 globs (above)Paths to skip scanning entirely.
custom_patternsVec<CustomSecretPattern>emptyOperator-defined detectors. Compiled at config-load time.

Algorithm

  1. Read action. Only FileWrite(path, content) and Patch(path, diff) proceed. Other actions return Verdict::Allow.
  2. Skip-path check. If any skip glob matches the path, return Allow.
  3. UTF-8 decode the content. Binary content (failed decode) returns empty matches and the call is allowed.
  4. Run every compiled detector regex against the text via find_iter. Any match denies.

Failure modes

  • Invalid custom_patterns regex :: SecretLeakConfigError::InvalidCustomPattern at with_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. Through build_default_or_fail_closed() it falls back to an unavailable_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

crates/guards/chio-guards/src/patch_integrity.rsrust
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

KnobTypeDefaultPurpose
enabledbooltrueMaster switch.
max_additionsusize1000Hard ceiling on added lines per patch.
max_deletionsusize500Hard ceiling on removed lines per patch.
forbidden_patternsVec<String>9 regexes (below)Patterns that, if matched on an added line, deny the patch.
require_balanceboolfalseWhen on, deny if additions/deletions exceed max_imbalance_ratio.
max_imbalance_ratiof6410.0Ratio cutoff (only applied when require_balance).

Default forbidden patterns

crates/guards/chio-guards/src/patch_integrity.rs47-62rust
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

  1. Iterate diff lines. Lines starting with + (excluding +++ headers) incrementadditions. Lines starting with - (excluding ---) increment deletions.
  2. For each added line, run every forbidden regex. Each hit appends a ForbiddenMatch.
  3. Compute imbalance_ratio = additions / deletions (or f64::INFINITY when deletions are zero and additions are not).
  4. Deny if any forbidden match was found, or any threshold was exceeded.

Failure modes

  • Invalid forbidden_patterns regex supplied via with_config :: PatchIntegrityConfigError::InvalidForbiddenPattern at construction, and the guard does not build. PatchIntegrityGuard::new() does not panic if the default set fails to compile: through build_default_or_fail_closed() it falls back to an enabled guard with an empty forbidden_regexes list.
  • Non-Patch actions return Verdict::Allow. The guard does not look at file writes; that is SecretLeakGuard'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:

crates/guards/chio-guards/src/pipeline.rs39-49rust
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