diff --git a/README.md b/README.md index b83726c..c89fd32 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 | diff --git a/examples/sandbox.yaml b/examples/sandbox.yaml index 691f309..f01d802 100644 --- a/examples/sandbox.yaml +++ b/examples/sandbox.yaml @@ -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" diff --git a/scode b/scode index 7dd2086..bbd8f70 100755 --- a/scode +++ b/scode @@ -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 @@ -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() { @@ -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 @@ -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 @@ -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) @@ -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 @@ -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" @@ -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 < ${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() { diff --git a/test/02_macos_sandbox.bats b/test/02_macos_sandbox.bats index a1b9217..3739902 100755 --- a/test/02_macos_sandbox.bats +++ b/test/02_macos_sandbox.bats @@ -747,3 +747,160 @@ YAML [ "$status" -ne 0 ] [[ "$output" != *"SHOULD_NOT_PRINT"* ]] } + +# ---------- Filesystem pattern rules (filesystem: config section) ---------- + +@test "filesystem: none rule emits deny after allows, before ro cap" { + local config_file="$TEST_PROJECT/fs-rules.yaml" + cat > "$config_file" < "$config_file" < "$config_file" < "$config_file" < "$config_file" < "$proj/.scode.yaml" < "$proj/.envrc" + cat > "$proj/.scode.yaml" < none' "$log_file" + grep -qF '"fsrules":[{"mode":"none","regex":"' "$log_file" + rm -rf "$proj" +} + +@test "macOS runtime: filesystem none rule denies nested .envrc reads" { + require_runtime_sandbox + local config_file="$TEST_PROJECT/fs-rules-runtime.yaml" + mkdir -p "$TEST_PROJECT/fs-nested" + echo "dotenv-secret" > "$TEST_PROJECT/fs-nested/.envrc" + cat > "$config_file" < "$config_file" < $TEST_PROJECT/fs-build/out.txt" + [ "$status" -eq 0 ] + [ -f "$TEST_PROJECT/fs-build/out.txt" ] + [[ "$(cat "$TEST_PROJECT/fs-build/out.txt")" == "ok" ]] +} + +@test "macOS runtime: filesystem none rule blocks read even with --allow" { + require_runtime_sandbox + local config_file="$TEST_PROJECT/fs-rules-allow.yaml" + mkdir -p "$TEST_PROJECT/fs-allowdir" + echo "keep" > "$TEST_PROJECT/fs-allowdir/.env" + cat > "$config_file" < "$config_file" < "$config_file" <