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.