Skip to main content

Recipe: Scoped file access

The problem​

The agent needs to read source code and scratch files. It never needs to read /etc/passwd, SSH private keys, or AWS credentials. A vulnerability in the model (or a prompt injection) could otherwise trick the agent into exfiltrating secrets from the filesystem.

The policy​

version: '1'
# Scoped file access.
#
# Allow file_read and file_write generally, but explicitly deny known
# dangerous paths. Ordering matters: deny rules evaluate first and win.
#
# The extractor emits `file_read:read` and `file_write:write` for
# every Read/Write/Edit tool call regardless of path. To enforce
# per-path scoping, rule evaluation uses the `path` arg via the
# `when:` condition block.
settings:
default_action: deny
default_on_missing: deny
default_on_tamper: deny
rules:
- id: deny-etc
deny: 'file_read:read'
when:
path: '/etc/*'
reason: 'System config paths are out of scope.'
- id: deny-etc-write
deny: 'file_write:write'
when:
path: '/etc/*'
reason: 'Writes to /etc are never allowed.'
- id: deny-root-home
deny: 'file_read:read'
when:
path: '/root/*'
reason: 'Root home is out of scope.'
- id: deny-ssh
deny: 'file_read:read'
when:
path: '*/.ssh/*'
reason: 'SSH keys are never read by the agent.'
- id: deny-aws-creds
deny: 'file_read:read'
when:
path: '*/.aws/*'
reason: 'AWS credentials are never read by the agent.'
- id: allow-tmp-read
allow: 'file_read:read'
when:
path: '/tmp/*'
reason: 'Scratch space is readable.'
- id: allow-tmp-write
allow: 'file_write:write'
when:
path: '/tmp/*'
reason: 'Scratch space is writable.'
- id: allow-repo-read
allow: 'file_read:read'
when:
path: '/workspace/*'
reason: 'Repo tree is readable.'
- id: allow-repo-write
allow: 'file_write:write'
when:
path: '/workspace/*'
reason: 'Repo tree is writable.'

Why it works​

For every file event declared as enforcing on Claude Code, Gemini CLI, Cursor IDE, and Kiro CLI, the extractor normalizes the host's Read, Write, or Edit call to file_read or file_write and emits :read or :write as the method. The path argument rides along in the call context; each rule's when: path: glob is matched against it. A matching deny stops the call before it executes.

Coverage is per event. Codex apply_patch, Codex MCP writes, and headless codex exec do not provide this enforcement path; Kiro IDE does not enforce.

What gets blocked​

Agent callExtracted actionDecisionreason_code
Read /etc/passwdfile_read:readdenyRULE_MATCH
Read /root/notes.txtfile_read:readdenyRULE_MATCH
Read ~/.ssh/id_rsafile_read:readdenyRULE_MATCH
Read ~/.aws/credentialsfile_read:readdenyRULE_MATCH
Write /etc/hostsfile_write:writedenyRULE_MATCH
Read /var/log/app.log (no allow rule)file_read:readdenyNO_RULE_MATCH

What gets allowed​

Agent callExtracted actionDecisionreason_code
Read /tmp/foo.txtfile_read:readallowRULE_MATCH
Write /tmp/scratch.jsonfile_write:writeallowRULE_MATCH
Read /workspace/src/app.pyfile_read:readallowRULE_MATCH

Test it yourself​

curl -O https://docs.controlzero.ai/recipes/scoped-file-access/policy.yaml
# scenarios.json is not hosted -- build it from the expected-decision table above.

# Requires a Python / Node SDK that ships the tool-extractor spec
# (coming in the 228 Phase 3 release). Confirm with:
# controlzero --version
# Run each documented case with the corresponding tool, method, and JSON arguments:
controlzero test <tool> --method <method> --policy policy.yaml --args '<json>'

Caveats​

  • Rules match path against the exact string the hook receives. Different agents pass paths differently: Claude Code passes absolute paths for Read/Write but relative paths for Edit. Point your rules at the form you actually see in the audit log. When in doubt, use a double-prefix pattern (*/.ssh/* catches both /home/me/.ssh/... and ~/.ssh/...).
  • Symbolic links bypass path globs. The agent reading /tmp/link-to-etc-passwd appears as /tmp/... in the path arg. Mitigate at the OS layer (agent user lacks read permission on /etc/passwd) -- governance is a second line of defense, not the only line.
  • Bash-wrapped file access (cat /etc/passwd) routes through the Bash extractor, not the file_read extractor. Pair this recipe with Block outbound network and add explicit Bash:cat / Bash:less rules if that is a concern.