Chio/Docs
LOGIN · JOIN

Filesystem Guards

See how four guards refuse file operations on credential paths, outside session roots, or carrying secrets and dangerous patches.

Concept chio@5f63e6e

On this page

An agent with write access to a workspace can still be refused a read of ~/.ssh/id_rsa, a write outside its session root, a file that holds an AWS key, or a patch that adds chmod 777. Four guards in chio-guards make those refusals inside admission, before the kernel dispatches the file operation.

The guards read the file action the integration reports: FileAccess, FileWrite, or Patch, with its path and content. Any other action passes them with Verdict::Allow. The host routes file operations through the kernel and confines the process that performs them; isolation comes from the host.

Policy matches the path a request resolves to

A path string can name one file and open another. The path guards compare policy against every form the path takes, and the resolved form wins when the forms disagree.

Filesystem checksChoose a request.
Default pipeline, registration order

The resolved path is the one policy matches

The lexical path looks like a workspace file, but it resolves through a symlink to /etc/shadow, one of the default forbidden patterns. forbidden-path denies, and the pipeline stops before the other guards run. Path normalization.

Action
FileAccess
Path
/workspace/reports/latest
Resolves to
/etc/shadow
Outcome
forbidden-path denies; later guards do not run
Illustrative file operations through the seven guards of GuardPipeline::default_pipeline(), in registration order; the four filesystem guards are drawn as checks. The exception comes from operator policy and the session root from the kernel’s session. Host confinement of the file operation itself is left out.

Three normalizers in crate::path_normalization produce the forms. normalize_path_for_policy is lexical: it strips . and .., collapses separators, and handles Windows backslashes. normalize_path_for_policy_with_fs resolves symlinks through the operating system, and normalize_path_for_policy_lexical_absolute makes the lexical form absolute without touching the filesystem.

When the resolved path differs from the lexical one, as it does through a symlink, the path guards require the resolved path to match policy. For a new file, resolution tries the parent directory; when resolution fails, normalization falls back to the lexical form. Between the check and the file operation the filesystem can change, so the host constrains the operation itself.

Credential paths are refused by default, with exceptions checked first

ForbiddenPathGuard, runtime name forbidden-path, denies access to paths that match a glob denylist. Its defaults cover SSH, AWS, GPG, Kubernetes, Docker, npm, 1Password, and password-store credentials, environment files, Git credentials, /etc/shadow, /etc/passwd, /etc/sudoers, and Windows credential stores, registry hives, and PowerShell profiles: 31 globs in all.

  1. Forms. The guard computes the lexical, resolved, and absolute lexical paths.
  2. Exceptions. A match against an exception allows the operation. When the resolved path differs from the lexical target, only the resolved path can match an exception; otherwise either form can.
  3. Patterns. The guard denies when either the resolved or the lexical form matches a forbidden pattern.

Configure it through a HushSpec policy with the plural forbidden_paths rule name. Operator globs go through with_patterns(patterns, exceptions), which fails closed: an invalid glob returns ForbiddenPathConfigError::InvalidPattern and the guard refuses to construct, so a typo leaves every block in place.

policy.yamlyaml
hushspec: "0.1.0"
rules:
  forbidden_paths:
    patterns:
      - "/secrets/**"
      - "**/.ssh/**"
      - "**/.env"
    exceptions:
      - "/workspace/tests/fixtures/.env.example"

An exception is checked before the patterns, so the fixture passes while every other .env is refused. chio policy explain shows both decisions without admitting either call:

Terminalbash
chio policy explain policy.yaml --server workspace --tool read_file \
  --params '{"path":"/workspace/tests/fixtures/.env.example"}'
chio policy explain policy.yaml --server workspace --tool read_file \
  --params '{"path":"/workspace/.env"}'
Output
call       workspace.read_file {"path":"/workspace/tests/fixtures/.env.example"}
1 forbidden-path   pass   exception /workspace/tests/fixtures/.env.example
stopped    after guard 1 of 1; decision allow

call       workspace.read_file {"path":"/workspace/.env"}
1 forbidden-path   deny   /workspace/.env matches **/.env
stopped    at guard 1 of 1; decision deny

Evaluation scans the compiled globs against the path bytes, and the filesystem-aware normalizer canonicalizes the target and, when needed, its parent, so filesystem access adds to the cost of each check.

Session roots bound every path, even with the allowlist off

PathAllowlistGuard, runtime name path-allowlist, is the dual of the denylist: inside an explicit allowlist, everything else is denied. It keeps separate lists for reads, writes, and patches, and an empty patch_allow falls back to file_write_allow.

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 kernel passes session_filesystem_roots on the GuardContext, the guard first requires the path to fall within at least one root. That check runs before the enabled flag is read, so a session-root violation denies with the guard disabled, and an empty root set denies everything. A path-bearing action the allowlist cannot classify is denied.

PathAllowlistGuard::new() constructs with enabled = false and empty lists. Supply a configuration through with_config before you rely on the allowlist; until then only the session roots narrow paths. When the resolved path differs from the lexical target, the resolved path must match the allowlist, so a symlink inside an allowed directory cannot open a target outside it.

Writes and patches that carry a credential stop before they land

SecretLeakGuard, runtime name secret-leak, scans the content of FileWrite and Patch actions against 18 built-in detectors and any custom patterns the operator adds.

  1. Action. Only FileWrite(path, content) and Patch(path, diff) proceed; other actions are allowed.
  2. Skip paths. A path matching a skip glob is allowed without a scan. The defaults exempt test files, so committed example tokens do not block writes.
  3. Decode. The content is decoded as UTF-8. Binary content yields no matches and the write is allowed.
  4. Scan. Every compiled detector runs over the text with find_iter; any match denies.

A hit is reported as a SecretMatch whose redacted value keeps the first four and last four characters and replaces the middle with *. A match shorter than eight characters becomes all asterisks.

An invalid custom regex fails construction with SecretLeakConfigError::InvalidCustomPattern, and a built-in that fails to compile returns SecretLeakConfigError::InvalidBuiltInPattern. SecretLeakGuard::new() never panics: through build_default_or_fail_closed() it falls back to unavailable_fail_closed(), whose one pattern [\s\S]+ matches all content and blocks every write and patch until the configuration is repaired.

A patch is bounded in size and screened line by line

PatchIntegrityGuard, runtime name patch-integrity, validates unified-diff payloads before they are applied. It reads only Patch actions; file writes belong to SecretLeakGuard.

  1. Count. Lines starting with +, excluding +++ headers, are additions; lines starting with -, excluding ---, are deletions.
  2. Screen. Every forbidden regex runs over each added line, and each hit appends a ForbiddenMatch.
  3. Balance. imbalance_ratio is additions divided by deletions, or f64::INFINITY when deletions are zero and additions are not.
  4. Decide. Any forbidden match, or any exceeded threshold, denies the patch.

The defaults cap a patch at 1,000 added and 500 removed lines and screen added lines with nine patterns that disable security checks, run destructive commands, or open shells. The balance check is off until require_balance is set, and then denies above max_imbalance_ratio, 10.0 by default.

An invalid regex passed through with_config fails construction with PatchIntegrityConfigError::InvalidForbiddenPattern.

The four guards run in the library default, interleaved with three others

GuardPipeline::default_pipeline() registers seven guards. The filesystem four sit apart: ShellCommandGuard, EgressAllowlistGuard, and McpToolGuard sit between them.

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
}

Path checks come before the content scanner and the patch analyzer, and content size and configured patterns set the cost of the later two. default_runtime_guard_profile() is a separate builder that installs none of these guards. Of the kernel’s three verdicts, Verdict::Allow, Verdict::Deny, and Verdict::PendingApproval, these guards return only the first two, and the verdict itself is bare; its reason lands in the receipt’s evidence.

Read more in Default Pipelines.

Filesystem guard types and defaults

ForbiddenPathGuard

Source crates/guards/chio-guards/src/forbidden_path.rs. Pattern is glob::Pattern; both lists compile once at construction, so matching a request parses nothing. The filter_map(|p| Pattern::new(p).ok()) path applies only to new(), which compiles the built-in defaults, all valid globs.

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;
}

Default forbidden patterns

Built by default_forbidden_patterns(). The Windows globs stay in the set on Unix, where they never match; the pattern set has no platform-conditional compilation.

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
}

ForbiddenPathGuard configuration

KnobTypeDefaultPurpose
patterns
Vec<String>
31 globs
Deny patterns; a match on either form denies.
exceptions
Vec<String>
empty
Globs that override a deny; checked first.

PathAllowlistGuard

Source crates/guards/chio-guards/src/path_allowlist.rs. The configuration holds three glob lists, and the guard holds their compiled form.

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>,
}
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>,
}

PathAllowlistGuard configuration

KnobTypeDefaultPurpose
enabled
bool
false
Off until the operator opts in; session roots still apply.
file_access_allow
Vec<String>
empty
Globs that allow reads.
file_write_allow
Vec<String>
empty
Globs that allow writes.
patch_allow
Vec<String>
empty
Globs that allow patches; empty falls back to file_write_allow.

SecretLeakGuard

Source crates/guards/chio-guards/src/secret_leak.rs. An operator pattern is a name and a regex string; the guard keeps the compiled form of both pattern sets and the skip list, and reports each hit as a SecretMatch with its position and a masked rendering.

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>,
}
crates/guards/chio-guards/src/secret_leak.rs26-29rust
pub struct CustomSecretPattern {
    pub name: String,
    pub pattern: String,
}
crates/guards/chio-guards/src/secret_leak.rs197-201rust
pub struct SecretLeakGuard {
    enabled: bool,
    patterns: Vec<CompiledPattern>,
    skip_paths: Vec<glob::Pattern>,
}
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 from default_patterns().

  • 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(). Matching paths bypass scanning entirely.

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(),
        }
    }
}

SecretLeakGuard configuration

KnobTypeDefaultPurpose
enabled
bool
true
Master switch.
skip_paths
Vec<String>
4 globs
Paths that skip scanning entirely.
custom_patterns
Vec<CustomSecretPattern>
empty
Operator detectors, compiled at config-load time.

PatchIntegrityGuard

Source crates/guards/chio-guards/src/patch_integrity.rs.

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,
}

PatchIntegrityGuard configuration

KnobTypeDefaultPurpose
enabled
bool
true
Master switch.
max_additions
usize
1000
Ceiling on added lines per patch.
max_deletions
usize
500
Ceiling on removed lines per patch.
forbidden_patterns
Vec<String>
9 regexes
A match on an added line denies the patch.
require_balance
bool
false
When on, deny above max_imbalance_ratio.
max_imbalance_ratio
f64
10.0
Additions-to-deletions cutoff, read only with require_balance.

Default patch 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(),
    ]
}

Next: compare the other guard families