Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,30 @@ Optional config file at `~/.config/scode/sandbox.yaml`. Entries are merged with

- `blocked:` adds to the default blocked list
- `allowed:` overrides blocks recursively (the path and all descendants), including defaults and your additions
- `filesystem:` glob pattern rules (macOS only) — see below
- Scalar options (`net`, `fs_mode`, `strict`, `scrub_env`, `grok_defense`) set defaults

### Filesystem pattern rules (macOS only)

`filesystem:` maps quoted glob patterns to access modes. Rules are emitted
after all allow rules (an `--allow` cannot reopen them) but before the
read-only cap, so `fs_mode: ro` still denies writes everywhere. Later rules
may narrow earlier ones.

```yaml
filesystem:
"~/**/.env": none # deny reads+writes to any .env under home
"~/**/.envrc": none
"~/src/**/build": write # read-write carve-out
```

Modes: `none` (deny all access), `read` (read-only), `write` (read-write).
Glob syntax: `**` matches across path segments (including `/`); `*` and `?`
match within a single segment. `~` prefixes expand to `$HOME`; relative
patterns anchor to the project directory. In project config (`.scode.yaml`)
only `none` rules are honored (untrusted input cannot grant access). On
Linux the section is ignored with a warning.

### Project config

A `.scode.yaml` file in the project root (the `--cwd` directory) is treated as untrusted input. It may tighten the policy by adding blocks, disabling network, enabling strict/env-scrub/Grok defense, or making the project read-only. It cannot add authoritative `allowed:` paths or turn protections off.
Expand Down Expand Up @@ -320,6 +342,11 @@ blocked:
# Allow specific directories, overriding defaults
allowed:
- ~/Documents/projects

# Filesystem pattern rules (macOS only)
filesystem:
"~/**/.env": none
"~/**/.envrc": none
```

| Config key | Values | Equivalent CLI flag |
Expand Down
9 changes: 9 additions & 0 deletions examples/sandbox.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@
# scrub_env: true # strip API keys from env (same as --scrub-env)
# grok_defense: true # harden Grok collection and project-secret access

# Filesystem pattern rules (macOS only)
# Quoted glob -> mode. Modes: none (deny all), read (read-only), write (read-write).
# ** matches across path segments; * and ? match within one segment.
# Relative patterns anchor to the project directory. Rules apply after allow
# rules (a floor --allow cannot reopen) and before the read-only cap.
# filesystem:
# "~/**/.env": none
# "~/**/.envrc": none

# Block additional directories beyond defaults
# Note: inline comments (space + #) are stripped from unquoted values.
# Paths containing literal " #" should be quoted: - "~/path with # in name"
Expand Down
192 changes: 190 additions & 2 deletions scode
Original file line number Diff line number Diff line change
Expand Up @@ -593,9 +593,23 @@ Config File:
scrub_env: true # same as --scrub-env
grok_defense: true # harden Grok collection + block repo history/secrets

Filesystem pattern rules (macOS only; quoted glob: mode):
filesystem:
"~/**/.env": none # deny reads and writes anywhere under home
"~/**/.envrc": none
"~/src/**/build": write # read-write carve-out
Modes: none (deny all), read (read-only), write (read-write).
Glob syntax: ** matches across directories, * and ? within one path
segment. Relative patterns anchor to the project directory. Rules are
emitted after allows (not overridable by --allow) but before the
read-only cap; later rules may narrow earlier ones.

Example:
strict: true
scrub_env: true
filesystem:
"~/**/.env": none
"~/**/.envrc": none
blocked:
- ~/.aws
- ~/.gnupg
Expand Down Expand Up @@ -1229,6 +1243,50 @@ sbpl_escape() {
printf '%s\n' "$p"
}

# Translate a scode filesystem glob into an anchored SBPL regex body.
# Supported syntax: ~ (home), ** (any run incl. /), * (any run except /),
# ? (single char except /). All other characters are matched literally.
# Output is the regex body only (no #"..."# wrapper, no anchors); the caller
# anchors with ^...$ . Returns 1 for an unsupported pattern.
glob_to_sbpl_regex() {
local pattern="$1"
local out="" i ch
for (( i=0; i<${#pattern}; i++ )); do
ch="${pattern:i:1}"
case "$ch" in
'*')
if [[ "${pattern:i+1:1}" == "*" ]]; then
out+=".*"
i=$((i + 1))
# Tolerate a following slash: `**/` == any path prefix
[[ "${pattern:i+1:1}" == "/" ]] && i=$((i + 1))
else
out+="[^/]*"
fi
;;
'?') out+="[^/]" ;;
'.') out+="\\." ;;
[a-zA-Z0-9/_-]) out+="$ch" ;;
*) return 1 ;;
esac
done
[[ -n "$out" ]] || return 1
printf '%s\n' "$out"
}

# Expand a filesystem-rule pattern: ~ prefix to $HOME, relative to project dir.
expand_fs_pattern() {
local pattern="$1"
local base_dir="$2"
validate_path "$pattern"
if [[ "$pattern" =~ ^~/ ]]; then
pattern="${HOME}${pattern:1}"
elif [[ "$pattern" != /* ]]; then
pattern="${base_dir}/${pattern}"
fi
printf '%s\n' "$pattern"
}

# Expand ~ and resolve path in the current shell context.
# Used for --cwd, --config, --log, and SCODE_CONFIG.
expand_path() {
Expand Down Expand Up @@ -1357,7 +1415,7 @@ parse_config() {
if [[ "$line" =~ ^[[:space:]]*([a-z_]+):[[:space:]]*(#.*)?$ ]]; then
current_section="${BASH_REMATCH[1]}"
case "$current_section" in
blocked|allowed) ;;
blocked|allowed|filesystem) ;;
*)
error "invalid config at line ${line_no}: unknown section '${current_section}' (${cfg_file})"
exit 1
Expand All @@ -1366,6 +1424,34 @@ parse_config() {
continue
fi

# filesystem section mapping entries: "glob/pattern": mode
# Quoted keys allow glob metacharacters; mode is none|read|write.
if [[ "$current_section" == "filesystem" ]]; then
if [[ "$line" =~ ^[[:space:]]*\"(([^\"\\]|\\.)*)\"[[:space:]]*:[[:space:]]*(.+)$ ]]; then
local fs_pattern_raw="${BASH_REMATCH[1]}"
local fs_mode_raw="${BASH_REMATCH[3]}"
local fs_pattern
if ! fs_pattern="$(parse_yaml_value "\"${fs_pattern_raw}\"")"; then
error "invalid config at line ${line_no}: malformed filesystem pattern (${cfg_file})"
exit 1
fi
if ! fs_mode_raw="$(parse_yaml_value "$fs_mode_raw")"; then
error "invalid config at line ${line_no}: malformed quoted value (${cfg_file})"
exit 1
fi
case "$fs_mode_raw" in
none|read|write) ;;
*) error "invalid config at line ${line_no}: filesystem mode '${fs_mode_raw}' (expected 'none', 'read', or 'write') (${cfg_file})"; exit 1 ;;
esac
validate_path "$fs_pattern"
# Safe: values are defused (\$...) before eval; no interpolation occurs.
eval "${prefix}_FS_RULES+=(\"\$fs_pattern|$fs_mode_raw\")"
continue
fi
error "invalid config at line ${line_no}: filesystem entries must be quoted \"pattern\": mode mappings (${cfg_file})"
exit 1
fi

# Scalar key: value (e.g., net: off, strict: true)
# Parsed regardless of current_section so scalars work after list blocks.
if [[ "$line" =~ ^[[:space:]]*([a-z_]+):[[:space:]]+(.+)$ ]]; then
Expand Down Expand Up @@ -1623,6 +1709,17 @@ write_log_header_json() {
done
printf ']'

# filesystem rules array
printf ',"fsrules":['
first=1
for _i in ${FS_RULES[@]+"${!FS_RULES[@]}"}; do
[[ "$first" -eq 1 ]] && first=0 || printf ','
printf '{"mode":"%s","regex":"%s"}' \
"$(json_escape "${FS_RULES[$_i]%%|*}")" \
"$(json_escape "${FS_RULES[$_i]#*|}")"
done
printf ']'

printf '}\n'

# Legacy comment header (human-readable, parsed by audit_log/audit_watch)
Expand All @@ -1638,6 +1735,9 @@ write_log_header_json() {
for _i in "${!HARNESS_STRICT_RO[@]}"; do
echo "# allowed: ${HARNESS_STRICT_RO[$_i]}"
done
for _i in ${FS_RULES_DEBUG[@]+"${!FS_RULES_DEBUG[@]}"}; do
echo "# fsrule: ${FS_RULES_DEBUG[$_i]}"
done

# Extra comment lines (e.g. profile dump, bwrap args)
for _line in "$@"; do
Expand Down Expand Up @@ -2025,7 +2125,10 @@ audit_watch() {
# SCODE_COVERAGE_EXCLUDE_START
# Seatbelt uses last-match precedence. Reassert custom blocks after broad
# carve-outs/automatic allows, then restore only authoritative CLI/user child
# allows. Finally cap the project at read-only when requested.
# allows. Filesystem pattern rules follow those allows (they are a security
# floor that explicit allows must not reopen) but precede the read-only
# project cap, which stays the final invariant. Finally cap the project at
# read-only when requested.
emit_macos_final_guards() {
local project_dir="$1"
local fs_mode="$2"
Expand Down Expand Up @@ -2069,6 +2172,46 @@ EOF
done
fi

# Filesystem pattern rules: last-match floor over everything above
# (default allows, project grants, explicit allows). Preserved in user
# order so a later, narrower rule can carve out an exception to an
# earlier one. The read-only cap below still wins for file-write.
if [[ ${FS_RULES[0]+_} ]]; then
local fs_rule fs_rule_mode fs_rule_regex
echo ""
echo "; Filesystem pattern rules"
for fs_rule in "${FS_RULES[@]}"; do
fs_rule_mode="${fs_rule%%|*}"
fs_rule_regex="${fs_rule#*|}"
case "$fs_rule_mode" in
none)
cat <<EOF
(deny file-read* file-write* process-exec
(regex #"^${fs_rule_regex}$")
)
EOF
;;
read)
cat <<EOF
(allow file-read* process-exec
(regex #"^${fs_rule_regex}$")
)
(deny file-write*
(regex #"^${fs_rule_regex}$")
)
EOF
;;
write)
cat <<EOF
(allow file-read* file-write*
(regex #"^${fs_rule_regex}$")
)
EOF
;;
esac
done
fi

if [[ "$fs_mode" == "ro" ]]; then
esc_project="$(sbpl_escape "$project_dir")"
cat <<EOF
Expand Down Expand Up @@ -2325,6 +2468,7 @@ generate_strict_profile() {
; a documented limitation of Apple's deprecated sandbox-exec interface.
(allow sysctl-read)
(allow mach-lookup)
(allow file-ioctl)

; Shared temp storage is denied; a private per-invocation directory is allowed
; below after this parent rule.
Expand Down Expand Up @@ -2741,6 +2885,7 @@ CLI_BLOCKED=()
CLI_ALLOWED=()
CONFIG_BLOCKED=()
CONFIG_ALLOWED=()
CONFIG_FS_RULES=()
# String configs init to "" (unset means "use default"); boolean configs also
# init to "" so explicit false can be distinguished from "not configured".
# parse_config() sets these via eval with a CONFIG_ prefix.
Expand Down Expand Up @@ -3086,6 +3231,7 @@ parse_config "${CONFIG_FILE}" "CONFIG"
PROJECT_CONFIG_FILE="${PROJECT_DIR%/}/.scode.yaml"
PROJECT_BLOCKED=()
PROJECT_ALLOWED=()
PROJECT_FS_RULES=()
PROJECT_NET=""
PROJECT_FS_MODE=""
PROJECT_STRICT=""
Expand Down Expand Up @@ -3214,6 +3360,48 @@ for dir in ${PROJECT_BLOCKED[@]+"${PROJECT_BLOCKED[@]}"}; do
RESOLVED_PROJECT_BLOCKED+=("$(resolve_access_path "$dir" "$PROJECT_DIR")")
done

# -------- Filesystem pattern rules (macOS only) --------
# Resolve "pattern|mode" entries from user config and project config.
# Relative patterns anchor to PROJECT_DIR; ~ expands to HOME. Project config
# is untrusted, so its rules may only deny (mode none).
FS_RULES=()
FS_RULES_DEBUG=()
if [[ "$PLATFORM" != "darwin" && ( ${CONFIG_FS_RULES[0]+_} || ${PROJECT_FS_RULES[0]+_} ) ]]; then
warning "filesystem rules are macOS-only; ignoring them on this platform"
fi
if [[ "$PLATFORM" == "darwin" ]]; then
_fs_rule_parse_failures=0
_resolve_fs_rules() {
local rules_name="$1"
local source_label="$2"
local allow_permissive="$3"
local rule pattern mode expanded regex_body
local -a rules=()
eval "rules=(\${${rules_name}[@]+\"\${${rules_name}[@]}\"})"
for rule in ${rules[@]+"${rules[@]}"}; do
pattern="${rule%%|*}"
mode="${rule##*|}"
if [[ "$allow_permissive" -eq 0 && "$mode" != "none" ]]; then
warning "ignoring project filesystem rule (project config is restrictive-only): ${pattern}: ${mode}"
continue
fi
expanded="$(expand_fs_pattern "$pattern" "$PROJECT_DIR")"
if ! regex_body="$(glob_to_sbpl_regex "$expanded")"; then
error "unsupported filesystem rule pattern: ${pattern}"
_fs_rule_parse_failures=$((_fs_rule_parse_failures + 1))
continue
fi
FS_RULES+=("${mode}|${regex_body}")
FS_RULES_DEBUG+=("${source_label} ${pattern} -> ${mode}")
done
}
_resolve_fs_rules CONFIG_FS_RULES config 1
_resolve_fs_rules PROJECT_FS_RULES project 0
if [[ "$_fs_rule_parse_failures" -gt 0 ]]; then
exit 1
fi
fi

# Project allow rules are never authoritative. An allow already covered by a
# CLI/user allow is harmless and redundant; every other project allow is ignored.
_report_ignored_project_allows() {
Expand Down
Loading