Add bounded scan resource limits
ober
8bced850074410550d091fb60aeb4665d0e814f9
--- a/README.md +++ b/README.md @@ -32,20 +32,27 @@ jerboa main-binary.ss scan /path/to/repo --format json Supported options are `--config FILE`, `--count N`, `--all`, `--from REV`, `--to REV`, `--commit REV`, `--file PATH`, `--include PATH`, -`--exclude PREFIX`, `--min-lines N`, `--format table|json|jsonl|markdown`, -`--metadata-only`, and `--heuristics-only`. +`--exclude PREFIX`, `--min-lines N`, `--max-files N`, +`--max-added-lines N`, `--max-note-bytes N`, +`--format table|json|jsonl|markdown`, `--metadata-only`, and +`--heuristics-only`. `--config FILE` reads guarded JSON. Supported scan keys are `path`, `count`, `format`, `from`, `to`, `file`, `exclude`, `min_lines`, `metadata_only`, and `heuristics_only`. Supported scoring keys are `human_threshold`, `ai_threshold`, `weight_text`, `weight_code`, `weight_structure`, -`weight_similarity`, `weight_history`, and `weight_baseline`. Later CLI flags -override earlier config values. +`weight_similarity`, `weight_history`, `weight_baseline`, `max_files`, +`max_added_lines`, and `max_note_bytes`. Later CLI flags override earlier +config values. By default, scans skip `vendor/`, `generated/`, `dist/`, `node_modules/`, and `.git/` paths. Use `--include PATH` or `--file PATH` to inspect one of those paths explicitly. +Resource limits default to 500 changed files per commit, 20,000 added lines +analyzed per commit, and 50,000 bytes per AI note. When a limit is hit, output +keeps the bounded data and includes a warning. + ## What It Reads The scanner runs Git commands against the requested repository using fixed --- a/main-binary.ss +++ b/main-binary.ss @@ -7,7 +7,7 @@ (defstruct options (command path count format commit from to file min-lines metadata-only? heuristics-only?)) (defstruct scan-config - (hash human-threshold ai-threshold text-weight code-weight structure-weight similarity-weight history-weight baseline-weight)) + (hash human-threshold ai-threshold text-weight code-weight structure-weight similarity-weight history-weight baseline-weight max-files max-added-lines max-note-bytes)) (defstruct signal (name category score weight confidence reason evidence limitations)) (defstruct finding (commit parent author-name author-email time subject files additions deletions @@ -23,7 +23,7 @@ xs))) (def (usage) - (displayln "usage: jerboa main-binary.ss scan [PATH] [--config FILE] [--count N|--all] [--from REV] [--to REV] [--file PATH|--include PATH] [--exclude PREFIX] [--min-lines N] [--format table|json|jsonl|markdown]") + (displayln "usage: jerboa main-binary.ss scan [PATH] [--config FILE] [--count N|--all] [--from REV] [--to REV] [--file PATH|--include PATH] [--exclude PREFIX] [--min-lines N] [--max-files N] [--max-added-lines N] [--max-note-bytes N] [--format table|json|jsonl|markdown]") (displayln " jerboa main-binary.ss explain REV [PATH] [--format json|markdown|table]") (displayln " jerboa main-binary.ss stats [PATH] [--count N]") (displayln " jerboa main-binary.ss verify-authorship [PATH] [--count N]")) @@ -83,9 +83,24 @@ (for/or ([prefix current-excludes]) (public-prefix? prefix path))) (def (selected-file-records records explicit-file) - (if explicit-file - records - (filter (lambda (record) (not (excluded-path? (car record)))) records))) + (let ([selected (if explicit-file + records + (filter (lambda (record) (not (excluded-path? (car record)))) records))]) + (bounded-list selected (scan-config-max-files current-config)))) +(def (bounded-list xs limit) + (if (and (> limit 0) (> (length xs) limit)) + (take xs limit) + xs)) + +(def (bounded-string s limit) + (if (and (> limit 0) (> (string-length s) limit)) + (substring s 0 limit) + s)) + +(def (limit-warning label actual limit) + (if (and (> limit 0) (> actual limit)) + (list (str label " truncated from " actual " to " limit)) + '())) (def (pathspec-args file) (if file (list "--" file) '())) @@ -122,6 +137,11 @@ (def (commit-message repo rev) (git repo (list "show" "-s" "--format=%B" rev))) (def (note-text repo rev) (string-trim (git repo (list "notes" "--ref=ai" "show" rev)))) +(def (bounded-note-text repo rev) + (bounded-string (note-text repo rev) (scan-config-max-note-bytes current-config))) + +(def (note-byte-count repo rev) + (string-length (note-text repo rev))) (def (parse-json-object text) (let ([parsed (try-result (with-input-from-string text read-json))]) (if (ok? parsed) (unwrap parsed) #f))) @@ -175,6 +195,14 @@ (filter (lambda (line) (not (blank? line))) (split-lines (git repo args))))]) (selected-file-records records file))) +(def (changed-file-count repo rev file) + (let* ([args (append (list "show" "--format=" "--numstat" "--first-parent" rev) + (pathspec-args file))] + [records (map parse-numstat + (filter (lambda (line) (not (blank? line))) + (split-lines (git repo args))))] + [selected (if file records (filter (lambda (record) (not (excluded-path? (car record)))) records))]) + (length selected))) (def (numstat-adds files) (sum (map cadr files))) (def (numstat-dels files) (sum (map caddr files))) @@ -191,6 +219,12 @@ (if explicit-file (added-lines repo rev explicit-file) (append-map (lambda (path) (added-lines repo rev path)) paths))) +(def (bounded-added-lines-for-paths repo rev paths explicit-file) + (bounded-list (added-lines-for-paths repo rev paths explicit-file) + (scan-config-max-added-lines current-config))) + +(def (added-line-count-for-paths repo rev paths explicit-file) + (length (added-lines-for-paths repo rev paths explicit-file))) (def known-agents '("codex" "copilot" "claude" "cursor" "openai" "anthropic" "aider" "windsurf" "cody" "tabnine" "ai-agent")) @@ -320,7 +354,7 @@ (str (stable-hash32 s))) (def (default-scan-config) - (make-scan-config "default" 0.20 0.50 0.10 0.35 0.15 0.15 0.15 0.00)) + (make-scan-config "default" 0.20 0.50 0.10 0.35 0.15 0.15 0.15 0.00 500 20000 50000)) (def (config-fingerprint cfg) (hash-string (string-join @@ -331,7 +365,10 @@ (str (scan-config-structure-weight cfg)) (str (scan-config-similarity-weight cfg)) (str (scan-config-history-weight cfg)) - (str (scan-config-baseline-weight cfg))) + (str (scan-config-baseline-weight cfg)) + (str (scan-config-max-files cfg)) + (str (scan-config-max-added-lines cfg)) + (str (scan-config-max-note-bytes cfg))) ":"))) (def (finalize-config cfg) @@ -343,7 +380,10 @@ (scan-config-structure-weight cfg) (scan-config-similarity-weight cfg) (scan-config-history-weight cfg) - (scan-config-baseline-weight cfg))) + (scan-config-baseline-weight cfg) + (scan-config-max-files cfg) + (scan-config-max-added-lines cfg) + (scan-config-max-note-bytes cfg))) (def current-config (finalize-config (default-scan-config))) @@ -428,9 +468,12 @@ [t (parse-int (safe-ref fields 4 "0") 0)]) (if (same-public-string? e author-id) (loop (cdr xs) (cons t out)) (loop (cdr xs) out)))))) -(def (warnings files lines note note-obj metadata-only? heuristics-only? min-lines) +(def (warnings files lines note note-obj metadata-only? heuristics-only? min-lines file-count line-count note-count) (append (if (null? files) '("no changed text files found or commit is unavailable") '()) (if (null? lines) '("no added UTF-8 patch lines available") '()) + (limit-warning "files" file-count (scan-config-max-files current-config)) + (limit-warning "added lines" line-count (scan-config-max-added-lines current-config)) + (limit-warning "AI note bytes" note-count (scan-config-max-note-bytes current-config)) (if (and (> min-lines 0) (< (length lines) min-lines)) (list (str "heuristics skipped below --min-lines " min-lines)) '()) @@ -443,9 +486,12 @@ [id (safe-ref fields 0 rev)] [parents (safe-ref fields 1 "")] [parent (first-parent parents)] [author-name (safe-ref fields 2 "")] [author-email (safe-ref fields 3 "")] [time (parse-int (safe-ref fields 4 "0") 0)] [subject (safe-ref fields 5 "")] - [body (commit-message repo rev)] [files (changed-files repo rev file)] [paths (numstat-paths files)] - [adds (numstat-adds files)] [dels (numstat-dels files)] [lines (added-lines-for-paths repo rev paths file)] - [note (note-text repo rev)] [note-obj (parse-note-object note)] + [body (commit-message repo rev)] [file-count (changed-file-count repo rev file)] + [files (changed-files repo rev file)] [paths (numstat-paths files)] + [adds (numstat-adds files)] [dels (numstat-dels files)] + [line-count (added-line-count-for-paths repo rev paths file)] + [lines (bounded-added-lines-for-paths repo rev paths file)] + [note-count (note-byte-count repo rev)] [note (bounded-note-text repo rev)] [note-obj (parse-note-object note)] [attribution (note-attributions note-obj)] [metadata (metadata-hits author-name author-email subject body note)] [eligible? (or (= min-lines 0) (>= (length lines) min-lines))] @@ -459,7 +505,7 @@ [score (if metadata-only? 0.0 (aggregate-score signals))] [v (if heuristics-only? (verdict score '() "") (verdict score metadata note))]) (list (make-finding id parent author-name author-email time subject paths adds dels (length lines) note attribution metadata signals score v - (warnings files lines note note-obj metadata-only? heuristics-only? min-lines)) + (warnings files lines note note-obj metadata-only? heuristics-only? min-lines file-count line-count note-count)) (cadr sim-pair)))) (def (scan-repo repo revs file min-lines metadata-only? heuristics-only?) @@ -588,7 +634,26 @@ (config-number obj "weight_structure" (scan-config-structure-weight current-config)) (config-number obj "weight_similarity" (scan-config-similarity-weight current-config)) (config-number obj "weight_history" (scan-config-history-weight current-config)) - (config-number obj "weight_baseline" (scan-config-baseline-weight current-config))))) + (config-number obj "weight_baseline" (scan-config-baseline-weight current-config)) + (config-number obj "max_files" (scan-config-max-files current-config)) + (config-number obj "max_added_lines" (scan-config-max-added-lines current-config)) + (config-number obj "max_note_bytes" (scan-config-max-note-bytes current-config))))) +(def (update-resource-limit field value) + (let ([n (parse-int value 0)]) + (set! current-config + (finalize-config + (make-scan-config "pending" + (scan-config-human-threshold current-config) + (scan-config-ai-threshold current-config) + (scan-config-text-weight current-config) + (scan-config-code-weight current-config) + (scan-config-structure-weight current-config) + (scan-config-similarity-weight current-config) + (scan-config-history-weight current-config) + (scan-config-baseline-weight current-config) + (if (same-public-string? field "max-files") n (scan-config-max-files current-config)) + (if (same-public-string? field "max-added-lines") n (scan-config-max-added-lines current-config)) + (if (same-public-string? field "max-note-bytes") n (scan-config-max-note-bytes current-config))))))) (def (apply-config opts path) (let ([obj (read-config-object path)]) @@ -666,6 +731,18 @@ (begin (set! current-excludes (cons (cadr xs) current-excludes)) (loop (cddr xs) opts path-set?))] + [(and (string=? (car xs) "--max-files") (pair? (cdr xs))) + (begin + (update-resource-limit "max-files" (cadr xs)) + (loop (cddr xs) opts path-set?))] + [(and (string=? (car xs) "--max-added-lines") (pair? (cdr xs))) + (begin + (update-resource-limit "max-added-lines" (cadr xs)) + (loop (cddr xs) opts path-set?))] + [(and (string=? (car xs) "--max-note-bytes") (pair? (cdr xs))) + (begin + (update-resource-limit "max-note-bytes" (cadr xs)) + (loop (cddr xs) opts path-set?))] [(and (string=? (car xs) "--min-lines") (pair? (cdr xs))) (loop (cddr xs) (make-options (options-command opts) (options-path opts) (options-count opts) (options-format opts) --- a/tests/fixture-smoke.sh +++ b/tests/fixture-smoke.sh @@ -96,6 +96,17 @@ exclude_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1 printf '%s\n' "$exclude_json" | grep -q '"files":\[\]' printf '%s\n' "$exclude_json" | grep -q 'no changed text files found or commit is unavailable' +max_file_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1 --include . --max-files 1) +printf '%s\n' "$max_file_json" | grep -q 'files truncated from 2 to 1' + +max_line_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1 --max-added-lines 3) +printf '%s\n' "$max_line_json" | grep -q '"added_lines":3' +printf '%s\n' "$max_line_json" | grep -q 'added lines truncated from 450 to 3' + +max_note_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1 --max-note-bytes 10) +printf '%s\n' "$max_note_json" | grep -q 'AI note bytes truncated from ' +printf '%s\n' "$max_note_json" | grep -q 'refs/notes/ai note is not supported JSON' + min_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1 --min-lines 999) printf '%s\n' "$min_json" | grep -q '"signals":\[\]' printf '%s\n' "$min_json" | grep -q 'heuristics skipped below --min-lines 999' @@ -114,6 +125,11 @@ printf '{"format":"json","count":1,"exclude":["src/"]}\n' > "$config_file" config_exclude_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$config_file") printf '%s\n' "$config_exclude_json" | grep -q '"files":\[\]' +printf '{"format":"json","count":1,"max_added_lines":2}\n' > "$config_file" +config_limit_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$config_file") +printf '%s\n' "$config_limit_json" | grep -q '"added_lines":2' +printf '%s\n' "$config_limit_json" | grep -q 'added lines truncated from 450 to 2' + printf '{"format":"json","count":1,"weight_code":0.0,"weight_text":0.0,"weight_structure":0.0,"weight_similarity":0.0,"weight_history":0.0,"ai_threshold":0.9,"human_threshold":0.1}\n' > "$config_file" weighted_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$config_file" --heuristics-only) printf '%s\n' "$weighted_json" | grep -q '"config_hash":"[0-9]'