Harden local provider command validation
ober
28105ae954e6b20f5f257708ceab8d17157a9fa5
--- a/GAPS.md +++ b/GAPS.md @@ -437,6 +437,10 @@ Acceptance criteria: - Warn or refuse group/world-writable provider executables. - Keep repo-local executable refusal. +Status: implemented with canonical `realpath` checks, direct symlink refusal, +group/world-writable mode refusal via fixed-argv `stat`, and fixture coverage +for repo-local, symlinked, and unsafe-mode local/embedding providers. + ## P2: Product and architecture parity ### G-050: Monolithic implementation --- a/main-binary.ss +++ b/main-binary.ss @@ -1017,14 +1017,62 @@ (def (path-inside-root? path root) (list-prefix-same? (string-split (macos-private-var-path root) #\/) (string-split (macos-private-var-path path) #\/))) +(def (process-succeeds? args) + (try + (begin (run-process args) #t) + (catch (e) #f))) + +(def (process-output/default args fallback) + (let ([out (try-result (run-process args))]) + (if (ok? out) (string-trim (unwrap out)) fallback))) + +(def (canonical-provider-path path) + (process-output/default (list "realpath" path) "")) + +(def (provider-type-text path) + (let ([mac-type (process-output/default (list "stat" "-f" "%HT" path) "")]) + (if (string-empty? mac-type) + (process-output/default (list "stat" "-c" "%F" path) "") + mac-type))) + +(def (provider-symlink? path) + (let ([kind (down (provider-type-text path))]) + (if (string-contains kind "symbolic") #t #f))) + +(def (provider-mode-text path) + (let ([mac-mode (process-output/default (list "stat" "-f" "%Lp" path) "")]) + (if (string-empty? mac-mode) + (process-output/default (list "stat" "-c" "%a" path) "") + mac-mode))) + +(def (writable-mode-digit? n) + (or (= n 2) (= n 3) (= n 6) (= n 7))) + +(def (mode-group-or-world-writable? text) + (let* ([mode (parse-int text 0)] + [group (modulo (quotient mode 10) 10)] + [world (modulo mode 10)]) + (or (writable-mode-digit? group) + (writable-mode-digit? world)))) + +(def (provider-common-warning repo cmd missing absolute inside symlink writable missing-path) + (cond [(null? cmd) missing] + [(not (path-absolute? (car cmd))) absolute] + [(provider-symlink? (car cmd)) symlink] + [else + (let ([canonical (canonical-provider-path (car cmd))]) + (cond [(string-empty? canonical) missing-path] + [(path-inside-root? canonical repo) inside] + [(mode-group-or-world-writable? (provider-mode-text canonical)) writable] + [else #f]))])) (def (local-provider-command-warning repo cmd) - (cond [(null? cmd) - "local provider requested but no local_provider_command config or JERBOA_AIGIT_LOCAL_PROVIDER is set"] - [(not (path-absolute? (car cmd))) - "local provider command must be an absolute path outside the scanned repository"] - [(path-inside-root? (car cmd) repo) - "local provider command inside the scanned repository was refused"] - [else #f])) + (provider-common-warning repo cmd + "local provider requested but no local_provider_command config or JERBOA_AIGIT_LOCAL_PROVIDER is set" + "local provider command must be an absolute path outside the scanned repository" + "local provider command inside the scanned repository was refused" + "local provider command must be canonical and not a symlink" + "local provider command is group/world-writable and was refused" + "local provider command path does not exist or cannot be canonicalized")) (def (local-provider-result repo rev subject paths lines signals score) (cond [(not current-llm-requested?) (list '() '())] [(not (same-public-string? current-requested-provider "local")) @@ -1056,13 +1104,13 @@ (cons 'files paths) (cons 'added_lines_sample (bounded-list lines local-provider-input-line-limit))))) (def (local-embedding-provider-warning repo cmd) - (cond [(null? cmd) - "embedding provider requested but no local_embedding_provider_command config or JERBOA_AIGIT_LOCAL_EMBEDDING_PROVIDER is set"] - [(not (path-absolute? (car cmd))) - "embedding provider command must be an absolute path outside the scanned repository"] - [(path-inside-root? (car cmd) repo) - "embedding provider command inside the scanned repository was refused"] - [else #f])) + (provider-common-warning repo cmd + "embedding provider requested but no local_embedding_provider_command config or JERBOA_AIGIT_LOCAL_EMBEDDING_PROVIDER is set" + "embedding provider command must be an absolute path outside the scanned repository" + "embedding provider command inside the scanned repository was refused" + "embedding provider command must be canonical and not a symlink" + "embedding provider command is group/world-writable and was refused" + "embedding provider command path does not exist or cannot be canonicalized")) (def (local-embedding-provider-signal obj) (let ([score (provider-score obj)]) (if score --- a/tests/fixture-smoke.sh +++ b/tests/fixture-smoke.sh @@ -404,6 +404,38 @@ repo_embedding_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$repo_e printf '%s\n' "$repo_embedding_json" | grep -q '"embeddings_used":false' printf '%s\n' "$repo_embedding_json" | grep -q 'embedding provider command inside the scanned repository was refused' +safe_provider_target="$provider_tmp/safe-provider-target.sh" +{ + printf '#!/usr/bin/env sh\n' + printf 'printf '\''{"score":0.50,"reason":"symlink target"}\\n'\''\n' +} > "$safe_provider_target" +chmod 755 "$safe_provider_target" +symlink_provider="$provider_tmp/symlink-provider.sh" +ln -s "$safe_provider_target" "$symlink_provider" +symlink_provider_config="$fixture/symlink-provider.json" +printf '{"local_provider_command":["%s"]}\n' "$symlink_provider" > "$symlink_provider_config" +symlink_provider_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$symlink_provider_config" --format json --count 1 --llm --provider local) +printf '%s\n' "$symlink_provider_json" | grep -q '"llm_used":false' +printf '%s\n' "$symlink_provider_json" | grep -q 'local provider command must be canonical and not a symlink' + +world_writable_provider="$provider_tmp/world-writable-provider.sh" +{ + printf '#!/usr/bin/env sh\n' + printf 'printf '\''{"score":0.50,"reason":"unsafe mode"}\\n'\''\n' +} > "$world_writable_provider" +chmod 777 "$world_writable_provider" +world_writable_provider_config="$fixture/world-writable-provider.json" +printf '{"local_provider_command":["%s"]}\n' "$world_writable_provider" > "$world_writable_provider_config" +world_writable_provider_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$world_writable_provider_config" --format json --count 1 --llm --provider local) +printf '%s\n' "$world_writable_provider_json" | grep -q '"llm_used":false' +printf '%s\n' "$world_writable_provider_json" | grep -q 'local provider command is group/world-writable and was refused' + +world_writable_embedding_config="$fixture/world-writable-embedding-provider.json" +printf '{"local_embedding_provider_command":["%s"]}\n' "$world_writable_provider" > "$world_writable_embedding_config" +world_writable_embedding_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$world_writable_embedding_config" --format json --count 1 --embeddings) +printf '%s\n' "$world_writable_embedding_json" | grep -q '"embeddings_used":false' +printf '%s\n' "$world_writable_embedding_json" | grep -q 'embedding provider command is group/world-writable and was refused' + provider_script="$provider_tmp/local-provider.sh" { printf '#!/usr/bin/env sh\n'