refuta 0.0.2__tar.gz → 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refuta
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Refuta — Refutation-driven Agentic Code Security Platform for DGX Spark
5
5
  Author: Refuta contributors
6
6
  Author-email: "DAPADA Inc." <contact@dapada.co.kr>
@@ -67,21 +67,24 @@ refuta/
67
67
  ├── expected.json # Ground truth used only by the independent evaluator (`refuta eval`)
68
68
  ├── results/ # fixture / qwen / qwen-cached run outputs and metrics
69
69
  └── tests/
70
- └── test_refuta.py # 20 unit, integration, and security-boundary regression tests
70
+ ├── test_refuta.py # Unit, integration, and security-boundary regression tests
71
+ └── test_local_diagnostics.py # Local-response failure and diagnostic privacy regressions
71
72
  ```
72
73
 
73
74
  ---
74
75
 
75
76
  ## 3. Benchmark and verification results
76
77
 
77
- On a real host vLLM (`nvidia/Qwen3.6-35B-A3B-NVFP4`, `:8000`), Refuta found **4/4 real vulnerabilities (including 1 `fixed_verified` through bwrap-isolated patch verification)**, **refuted 2/2 safe cases protected by upstream guards (`refuted`, 0 false positives)**, with **0 quote or line-number evidence errors**, **0 raw secret leaks**, and the **untrusted `AGENTS.md` quarantined (`quarantine_verified: true`)**. All **20 unit and regression tests pass** (`1.74s`).
78
+ On a real host vLLM (`nvidia/Qwen3.6-35B-A3B-NVFP4`, `:8000`), the recorded run (`--samples 3`) found **4/4 real vulnerabilities (including 1 `fixed_verified` through bwrap-isolated patch verification)**, **refuted 2/2 safe cases protected by upstream guards (`refuted`, 0 false positives)**, with **0 quote or line-number evidence errors**, **0 raw secret leaks**, and the **untrusted `AGENTS.md` quarantined (`quarantine_verified: true`)**. At recording time, all **22 unit and regression tests passed** (`1.46s`).
78
79
 
79
80
  | Mode | Vulnerabilities found | Safe cases refuted | False positives | Evidence errors | Secrets exposed | Model calls | Time |
80
81
  | --- | --- | --- | --- | --- | --- | --- | --- |
81
82
  | Baseline scanners only (reference) | 4/4 | 0/2 | **2** | — | 0 | 0 | 0.005 s |
82
- | Canned responses (`fixture`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 5 (stub) | 0.283 s |
83
- | Real Qwen3.6 audit (`local`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 5 | 35.012 s |
84
- | Re-run from cached investigations (`qwen-cached`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 0 (5 cache hits) | 0.273 s |
83
+ | Canned responses (`fixture`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 5 (stub) | 0.354 s |
84
+ | Real Qwen3.6 audit (`local`, `--samples 3`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 15 | 114.173 s |
85
+ | Re-run from cached investigations (`qwen-cached`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 0 (15 cache hits) | 0.296 s |
86
+
87
+ The `local` row is one recorded run. vLLM output is not bit-exact at temperature 0, so a single investigation can flip: on 2026-10-26, single-sample runs (`--samples 1`) matched the ground truth in 6 of 8 runs, and both failures left `CASE-02-GUARDED-ADMIN` `confirmed` instead of `refuted` (1 false positive). With `--samples 3` (strict-majority vote over independently adjudicated investigations), 10 of 11 runs matched; the other had no majority on CASE-02 and escalated it to `needs_review`. No run in either mode missed a vulnerability, produced an evidence error, or exposed a secret.
85
88
 
86
89
  - Real Qwen run: [report](https://github.com/memtomem/refuta/blob/main/results/qwen/report.md), [structured result](https://github.com/memtomem/refuta/blob/main/results/qwen/result.json), [SARIF](https://github.com/memtomem/refuta/blob/main/results/qwen/sarif.json), [metrics](https://github.com/memtomem/refuta/blob/main/results/qwen/metrics.json)
87
90
  - Cached re-run: [report](https://github.com/memtomem/refuta/blob/main/results/qwen-cached/report.md) and [metrics](https://github.com/memtomem/refuta/blob/main/results/qwen-cached/metrics.json)
@@ -94,8 +97,8 @@ On a real host vLLM (`nvidia/Qwen3.6-35B-A3B-NVFP4`, `:8000`), Refuta found **4/
94
97
  Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip install refuta`, use `refuta` instead of `.venv/bin/refuta`.
95
98
 
96
99
  ```bash
97
- # 1) All unit and integration tests (20)
98
- .venv/bin/pytest -q tests/test_refuta.py
100
+ # 1) All unit and integration tests
101
+ .venv/bin/pytest -q
99
102
 
100
103
  # 2) Static, secret, and authorization scanners only
101
104
  .venv/bin/refuta scan \
@@ -111,6 +114,7 @@ Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip i
111
114
  .venv/bin/refuta audit \
112
115
  --repo sample/repo \
113
116
  --runner local \
117
+ --samples 3 \
114
118
  --patches patches/manifest.json \
115
119
  --out results/qwen
116
120
 
@@ -123,11 +127,23 @@ Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip i
123
127
  # Offline, no model needed: --runner fixture --fixture fixtures/responses.json
124
128
  ```
125
129
 
126
- `audit` exits 0 when the run completes and 2 when some candidates failed (partial); `eval` exits 1 when the result does not match the ground truth.
130
+ `audit` defaults to 3 independent investigations per candidate for `--runner local`, with a strict-majority vote; other runners default to 1. Set `--samples N` to override either default (`N >= 1`). On an uncached audit, the local default uses about three times as many model calls as `--samples 1`. Existing sample-0 cache entries are reused; only missing samples require new calls.
131
+
132
+ Local investigations run sequentially, and remaining samples are still attempted after a failure. The HTTP timeout (`RepoPolicy.max_timeout_sec`, default 120 seconds) applies to each request rather than setting a deadline for the entire audit. A stalled endpoint can therefore cause three consecutive timeout waits per candidate with the local default. Use `--samples 1` to reduce the number of requests when troubleshooting the endpoint.
133
+
134
+ `audit` exits 0 when the run completes and 2 when any investigation sample fails (`partial`), even if the other samples reach a strict majority and decide the finding. Check `result.json.errors` for failed samples and each finding's `sample_statuses` for the vote. `eval` exits 1 when the result does not match the ground truth.
135
+
136
+ ### Local-response diagnostics
137
+
138
+ When a local model response has invalid UTF-8, malformed JSON, an invalid response envelope, or investigation content that is not a JSON object, the audit automatically saves a diagnostic under `<out>/diagnostics/<run_id>/<candidate_id>-sample<N>.json`. The sample number is zero-based. Each entry in `result.json.errors` points to its diagnostic; the same error list is saved in the audit's SQLite record. Failed responses are not cached. Successful responses create no diagnostic files, and a diagnostic write failure is recorded while the audit continues as `partial`.
139
+
140
+ Diagnostics contain the model, failure stage, response text, completion reason and token usage when available, and candidate/rule/sample identity. JSON errors also include the exact parser input after redaction, with character position and one-based line/column coordinates. Secrets in that parser input use equal-length `*` masks so the coordinates remain accurate after code-fence or reasoning-block removal and fallback extraction. Other diagnostic text uses the existing hashed secret markers, including for tokens split by Unicode escapes inside nested JSON or following short escapes such as `\n`. Diagnostic masking conservatively recognizes token prefixes within surrounding text and extends masking beyond scanner length caps; it can redact more text than the source scanner. The serialized diagnostic is checked again before writing.
141
+
142
+ Diagnostic directories are restricted to the owner (`0700`), files use `0600`, and symlink directories are rejected. Diagnostics are ignored by Git. Redaction covers the existing GitHub-token, `sk-`, and AWS-access-key families; truncated tokens or tokens interrupted by invalid encoding may not match. A field with unresolved escape obfuscation is replaced entirely with a redaction marker; parser input instead uses stars while preserving its length and line breaks. Diagnostics can still contain source code or other sensitive values and should be handled as sensitively as the audited snapshot. These permissions apply to diagnostics, not to the audit's other output files. HTTP errors and timeouts retain their existing error handling without response-body capture.
127
143
 
128
144
  ---
129
145
 
130
- ## 5. Security and verification boundaries covered by tests (`tests/test_refuta.py`)
146
+ ## 5. Security and verification boundaries covered by tests
131
147
 
132
148
  1. **Untrusted instruction-file quarantine and credential stripping (`RepoProvider`)**: when a snapshot is created, tokens in `.git/config` are removed and `AGENTS.md`, `CLAUDE.md`, `.claude/`, `.codex/`, and `.github/copilot-instructions.md` are quarantined automatically.
133
149
  2. **Loop 1 diff scope (`compute_diff_scope`)**: computes exactly which files were added, modified, or deleted between two snapshots.
@@ -136,19 +152,21 @@ Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip i
136
152
  5. **Deterministic evidence verifier (`verifier.py`)**: checks that the `vulnerable_quotes` and `guard_quotes` the model cites exist verbatim in the original files, and computes line numbers itself. Missing files, path escapes (`../`), and altered code quotes are rejected immediately as `invalid_evidence`.
137
153
  6. **Strict refutation**: even if the model claims `is_refuted: true`, the refutation is rejected (`needs_review`) unless it presents guard-code quotes (`guard_quotes`) verified against the snapshot files.
138
154
  7. **Option D cross-model review (`adjudicate_cross_review`)**: adjudicates agreement and disagreement between the primary analyst and a second refuting reviewer, switching to `refuted` only when the refuting side presents a valid guard-code quote.
139
- 8. **`bwrap` (bubblewrap) isolated patch and scanner runner (`sandbox.py`)**:
155
+ 8. **Sample voting (`adjudicate_samples`, `audit --samples N`)**: runs N independent investigations per candidate, adjudicates each one separately, and keeps a status only when a strict majority reaches it; anything else escalates to `needs_review`. A sample counts as `refuted` only if its guard quote verified, so a single-sample refutation without evidence cannot win the vote. Per-sample statuses are kept in `sample_statuses`, and each sample has its own cache entry.
156
+ 9. **`bwrap` (bubblewrap) isolated patch and scanner runner (`sandbox.py`)**:
140
157
  - Isolates test and scanner processes with `/usr/bin/bwrap` (`--unshare-all --die-with-parent --new-session --ro-bind / / --tmpfs $HOME --ro-bind <venv> <venv> --ro-bind <base interpreter under $HOME, e.g. uv Python> --tmpfs /tmp --ro-bind <work_repo> /tmp/work --clearenv`).
141
158
  - Demonstrated inside the sandbox: (1) `$HOME` is not accessible, (2) the source under `/tmp/work` cannot be written (`Read-only file system`), (3) neither the host's `127.0.0.1:8000` nor external networks are reachable (`Network is unreachable`).
142
159
  - A finding is promoted to `fixed_verified` only when all four conditions hold: (1) test files (`tests/`) are unmodified, (2) the security test fails before the patch (the vulnerability reproduces), (3) the security test passes after the patch, (4) the functional regression tests pass after the patch.
143
160
  - Execution is blocked when an external binary or model weight does not match its pinned SHA256 digest (`verify_artifact_sha256`).
144
- 9. **Default deny for sending code off-host, and a memory guard (`RunnerRouter`)**: with `local_only=True` (the default), `ClaudeRunner` and external `CodexRunner` calls are blocked; local inference is refused with `InsufficientMemoryError` when `MemAvailable` is below the threshold (`min_mem_available_gb`, default 8).
145
- 10. **Accepted-risk expiry and invalidation on file change (`RefutaStore`)**: an `accepted_risk` exception is released and the finding restored to `confirmed` when the exception expires or the SHA256 of the target file changes.
146
- 11. **Continuous security loops and publishing safeguards (`loops.py`, `publisher.py`)**:
161
+ 10. **Default deny for sending code off-host, and a memory guard (`RunnerRouter`)**: with `local_only=True` (the default), `ClaudeRunner` and external `CodexRunner` calls are blocked; local inference is refused with `InsufficientMemoryError` when `MemAvailable` is below the threshold (`min_mem_available_gb`, default 8).
162
+ 11. **Accepted-risk expiry and invalidation on file change (`RefutaStore`)**: an `accepted_risk` exception is released and the finding restored to `confirmed` when the exception expires or the SHA256 of the target file changes.
163
+ 12. **Continuous security loops and publishing safeguards (`loops.py`, `publisher.py`)**:
147
164
  - **Loop 3 (new-CVE response)**: queries the deployment/SBOM graph to identify affected services, environments, and runtime images immediately, without any code change.
148
165
  - **Loop 4 (attack-surface reduction)**: automatically identifies unauthenticated public routes, debug endpoints, and privileged admin paths.
149
166
  - **Loop 6 (post-deploy re-verification)**: fails while a pre-patch commit is still running in production, and completes only once the patched commit is deployed and passes its health check.
150
167
  - **Quiet-window guard (`QuietWindow`, `is_in_quiet_window`)**: tells heavy scheduled audits when to yield the host, e.g. to another model server sharing the GPU. Windows are configured by the caller (time zone, start/end, weekdays); the test uses a DGX Spark schedule (Mon/Thu 03:50–09:00 KST, daily 06:50–07:30 KST) as its example.
151
168
  - **Publishing safeguards**: GitHub webhook HMAC-SHA256 signature verification, blocking publication on a commit SHA mismatch (`StaleCommitPublishError`), and automatic redaction of secret tokens in outbound text.
169
+ 13. **Local-response diagnostics (`tests/test_local_diagnostics.py`)**: redacts supported tokens (including overlapping patterns and Unicode escapes), preserves parser coordinates, separates runs, restricts file permissions, rejects symlink directories, and keeps partial audits usable when diagnostic writes fail. Non-object local responses are rejected before caching, and malformed legacy cache entries are re-investigated.
152
170
 
153
171
  ---
154
172
 
@@ -41,21 +41,24 @@ refuta/
41
41
  ├── expected.json # Ground truth used only by the independent evaluator (`refuta eval`)
42
42
  ├── results/ # fixture / qwen / qwen-cached run outputs and metrics
43
43
  └── tests/
44
- └── test_refuta.py # 20 unit, integration, and security-boundary regression tests
44
+ ├── test_refuta.py # Unit, integration, and security-boundary regression tests
45
+ └── test_local_diagnostics.py # Local-response failure and diagnostic privacy regressions
45
46
  ```
46
47
 
47
48
  ---
48
49
 
49
50
  ## 3. Benchmark and verification results
50
51
 
51
- On a real host vLLM (`nvidia/Qwen3.6-35B-A3B-NVFP4`, `:8000`), Refuta found **4/4 real vulnerabilities (including 1 `fixed_verified` through bwrap-isolated patch verification)**, **refuted 2/2 safe cases protected by upstream guards (`refuted`, 0 false positives)**, with **0 quote or line-number evidence errors**, **0 raw secret leaks**, and the **untrusted `AGENTS.md` quarantined (`quarantine_verified: true`)**. All **20 unit and regression tests pass** (`1.74s`).
52
+ On a real host vLLM (`nvidia/Qwen3.6-35B-A3B-NVFP4`, `:8000`), the recorded run (`--samples 3`) found **4/4 real vulnerabilities (including 1 `fixed_verified` through bwrap-isolated patch verification)**, **refuted 2/2 safe cases protected by upstream guards (`refuted`, 0 false positives)**, with **0 quote or line-number evidence errors**, **0 raw secret leaks**, and the **untrusted `AGENTS.md` quarantined (`quarantine_verified: true`)**. At recording time, all **22 unit and regression tests passed** (`1.46s`).
52
53
 
53
54
  | Mode | Vulnerabilities found | Safe cases refuted | False positives | Evidence errors | Secrets exposed | Model calls | Time |
54
55
  | --- | --- | --- | --- | --- | --- | --- | --- |
55
56
  | Baseline scanners only (reference) | 4/4 | 0/2 | **2** | — | 0 | 0 | 0.005 s |
56
- | Canned responses (`fixture`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 5 (stub) | 0.283 s |
57
- | Real Qwen3.6 audit (`local`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 5 | 35.012 s |
58
- | Re-run from cached investigations (`qwen-cached`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 0 (5 cache hits) | 0.273 s |
57
+ | Canned responses (`fixture`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 5 (stub) | 0.354 s |
58
+ | Real Qwen3.6 audit (`local`, `--samples 3`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 15 | 114.173 s |
59
+ | Re-run from cached investigations (`qwen-cached`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 0 (15 cache hits) | 0.296 s |
60
+
61
+ The `local` row is one recorded run. vLLM output is not bit-exact at temperature 0, so a single investigation can flip: on 2026-10-26, single-sample runs (`--samples 1`) matched the ground truth in 6 of 8 runs, and both failures left `CASE-02-GUARDED-ADMIN` `confirmed` instead of `refuted` (1 false positive). With `--samples 3` (strict-majority vote over independently adjudicated investigations), 10 of 11 runs matched; the other had no majority on CASE-02 and escalated it to `needs_review`. No run in either mode missed a vulnerability, produced an evidence error, or exposed a secret.
59
62
 
60
63
  - Real Qwen run: [report](https://github.com/memtomem/refuta/blob/main/results/qwen/report.md), [structured result](https://github.com/memtomem/refuta/blob/main/results/qwen/result.json), [SARIF](https://github.com/memtomem/refuta/blob/main/results/qwen/sarif.json), [metrics](https://github.com/memtomem/refuta/blob/main/results/qwen/metrics.json)
61
64
  - Cached re-run: [report](https://github.com/memtomem/refuta/blob/main/results/qwen-cached/report.md) and [metrics](https://github.com/memtomem/refuta/blob/main/results/qwen-cached/metrics.json)
@@ -68,8 +71,8 @@ On a real host vLLM (`nvidia/Qwen3.6-35B-A3B-NVFP4`, `:8000`), Refuta found **4/
68
71
  Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip install refuta`, use `refuta` instead of `.venv/bin/refuta`.
69
72
 
70
73
  ```bash
71
- # 1) All unit and integration tests (20)
72
- .venv/bin/pytest -q tests/test_refuta.py
74
+ # 1) All unit and integration tests
75
+ .venv/bin/pytest -q
73
76
 
74
77
  # 2) Static, secret, and authorization scanners only
75
78
  .venv/bin/refuta scan \
@@ -85,6 +88,7 @@ Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip i
85
88
  .venv/bin/refuta audit \
86
89
  --repo sample/repo \
87
90
  --runner local \
91
+ --samples 3 \
88
92
  --patches patches/manifest.json \
89
93
  --out results/qwen
90
94
 
@@ -97,11 +101,23 @@ Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip i
97
101
  # Offline, no model needed: --runner fixture --fixture fixtures/responses.json
98
102
  ```
99
103
 
100
- `audit` exits 0 when the run completes and 2 when some candidates failed (partial); `eval` exits 1 when the result does not match the ground truth.
104
+ `audit` defaults to 3 independent investigations per candidate for `--runner local`, with a strict-majority vote; other runners default to 1. Set `--samples N` to override either default (`N >= 1`). On an uncached audit, the local default uses about three times as many model calls as `--samples 1`. Existing sample-0 cache entries are reused; only missing samples require new calls.
105
+
106
+ Local investigations run sequentially, and remaining samples are still attempted after a failure. The HTTP timeout (`RepoPolicy.max_timeout_sec`, default 120 seconds) applies to each request rather than setting a deadline for the entire audit. A stalled endpoint can therefore cause three consecutive timeout waits per candidate with the local default. Use `--samples 1` to reduce the number of requests when troubleshooting the endpoint.
107
+
108
+ `audit` exits 0 when the run completes and 2 when any investigation sample fails (`partial`), even if the other samples reach a strict majority and decide the finding. Check `result.json.errors` for failed samples and each finding's `sample_statuses` for the vote. `eval` exits 1 when the result does not match the ground truth.
109
+
110
+ ### Local-response diagnostics
111
+
112
+ When a local model response has invalid UTF-8, malformed JSON, an invalid response envelope, or investigation content that is not a JSON object, the audit automatically saves a diagnostic under `<out>/diagnostics/<run_id>/<candidate_id>-sample<N>.json`. The sample number is zero-based. Each entry in `result.json.errors` points to its diagnostic; the same error list is saved in the audit's SQLite record. Failed responses are not cached. Successful responses create no diagnostic files, and a diagnostic write failure is recorded while the audit continues as `partial`.
113
+
114
+ Diagnostics contain the model, failure stage, response text, completion reason and token usage when available, and candidate/rule/sample identity. JSON errors also include the exact parser input after redaction, with character position and one-based line/column coordinates. Secrets in that parser input use equal-length `*` masks so the coordinates remain accurate after code-fence or reasoning-block removal and fallback extraction. Other diagnostic text uses the existing hashed secret markers, including for tokens split by Unicode escapes inside nested JSON or following short escapes such as `\n`. Diagnostic masking conservatively recognizes token prefixes within surrounding text and extends masking beyond scanner length caps; it can redact more text than the source scanner. The serialized diagnostic is checked again before writing.
115
+
116
+ Diagnostic directories are restricted to the owner (`0700`), files use `0600`, and symlink directories are rejected. Diagnostics are ignored by Git. Redaction covers the existing GitHub-token, `sk-`, and AWS-access-key families; truncated tokens or tokens interrupted by invalid encoding may not match. A field with unresolved escape obfuscation is replaced entirely with a redaction marker; parser input instead uses stars while preserving its length and line breaks. Diagnostics can still contain source code or other sensitive values and should be handled as sensitively as the audited snapshot. These permissions apply to diagnostics, not to the audit's other output files. HTTP errors and timeouts retain their existing error handling without response-body capture.
101
117
 
102
118
  ---
103
119
 
104
- ## 5. Security and verification boundaries covered by tests (`tests/test_refuta.py`)
120
+ ## 5. Security and verification boundaries covered by tests
105
121
 
106
122
  1. **Untrusted instruction-file quarantine and credential stripping (`RepoProvider`)**: when a snapshot is created, tokens in `.git/config` are removed and `AGENTS.md`, `CLAUDE.md`, `.claude/`, `.codex/`, and `.github/copilot-instructions.md` are quarantined automatically.
107
123
  2. **Loop 1 diff scope (`compute_diff_scope`)**: computes exactly which files were added, modified, or deleted between two snapshots.
@@ -110,19 +126,21 @@ Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip i
110
126
  5. **Deterministic evidence verifier (`verifier.py`)**: checks that the `vulnerable_quotes` and `guard_quotes` the model cites exist verbatim in the original files, and computes line numbers itself. Missing files, path escapes (`../`), and altered code quotes are rejected immediately as `invalid_evidence`.
111
127
  6. **Strict refutation**: even if the model claims `is_refuted: true`, the refutation is rejected (`needs_review`) unless it presents guard-code quotes (`guard_quotes`) verified against the snapshot files.
112
128
  7. **Option D cross-model review (`adjudicate_cross_review`)**: adjudicates agreement and disagreement between the primary analyst and a second refuting reviewer, switching to `refuted` only when the refuting side presents a valid guard-code quote.
113
- 8. **`bwrap` (bubblewrap) isolated patch and scanner runner (`sandbox.py`)**:
129
+ 8. **Sample voting (`adjudicate_samples`, `audit --samples N`)**: runs N independent investigations per candidate, adjudicates each one separately, and keeps a status only when a strict majority reaches it; anything else escalates to `needs_review`. A sample counts as `refuted` only if its guard quote verified, so a single-sample refutation without evidence cannot win the vote. Per-sample statuses are kept in `sample_statuses`, and each sample has its own cache entry.
130
+ 9. **`bwrap` (bubblewrap) isolated patch and scanner runner (`sandbox.py`)**:
114
131
  - Isolates test and scanner processes with `/usr/bin/bwrap` (`--unshare-all --die-with-parent --new-session --ro-bind / / --tmpfs $HOME --ro-bind <venv> <venv> --ro-bind <base interpreter under $HOME, e.g. uv Python> --tmpfs /tmp --ro-bind <work_repo> /tmp/work --clearenv`).
115
132
  - Demonstrated inside the sandbox: (1) `$HOME` is not accessible, (2) the source under `/tmp/work` cannot be written (`Read-only file system`), (3) neither the host's `127.0.0.1:8000` nor external networks are reachable (`Network is unreachable`).
116
133
  - A finding is promoted to `fixed_verified` only when all four conditions hold: (1) test files (`tests/`) are unmodified, (2) the security test fails before the patch (the vulnerability reproduces), (3) the security test passes after the patch, (4) the functional regression tests pass after the patch.
117
134
  - Execution is blocked when an external binary or model weight does not match its pinned SHA256 digest (`verify_artifact_sha256`).
118
- 9. **Default deny for sending code off-host, and a memory guard (`RunnerRouter`)**: with `local_only=True` (the default), `ClaudeRunner` and external `CodexRunner` calls are blocked; local inference is refused with `InsufficientMemoryError` when `MemAvailable` is below the threshold (`min_mem_available_gb`, default 8).
119
- 10. **Accepted-risk expiry and invalidation on file change (`RefutaStore`)**: an `accepted_risk` exception is released and the finding restored to `confirmed` when the exception expires or the SHA256 of the target file changes.
120
- 11. **Continuous security loops and publishing safeguards (`loops.py`, `publisher.py`)**:
135
+ 10. **Default deny for sending code off-host, and a memory guard (`RunnerRouter`)**: with `local_only=True` (the default), `ClaudeRunner` and external `CodexRunner` calls are blocked; local inference is refused with `InsufficientMemoryError` when `MemAvailable` is below the threshold (`min_mem_available_gb`, default 8).
136
+ 11. **Accepted-risk expiry and invalidation on file change (`RefutaStore`)**: an `accepted_risk` exception is released and the finding restored to `confirmed` when the exception expires or the SHA256 of the target file changes.
137
+ 12. **Continuous security loops and publishing safeguards (`loops.py`, `publisher.py`)**:
121
138
  - **Loop 3 (new-CVE response)**: queries the deployment/SBOM graph to identify affected services, environments, and runtime images immediately, without any code change.
122
139
  - **Loop 4 (attack-surface reduction)**: automatically identifies unauthenticated public routes, debug endpoints, and privileged admin paths.
123
140
  - **Loop 6 (post-deploy re-verification)**: fails while a pre-patch commit is still running in production, and completes only once the patched commit is deployed and passes its health check.
124
141
  - **Quiet-window guard (`QuietWindow`, `is_in_quiet_window`)**: tells heavy scheduled audits when to yield the host, e.g. to another model server sharing the GPU. Windows are configured by the caller (time zone, start/end, weekdays); the test uses a DGX Spark schedule (Mon/Thu 03:50–09:00 KST, daily 06:50–07:30 KST) as its example.
125
142
  - **Publishing safeguards**: GitHub webhook HMAC-SHA256 signature verification, blocking publication on a commit SHA mismatch (`StaleCommitPublishError`), and automatic redaction of secret tokens in outbound text.
143
+ 13. **Local-response diagnostics (`tests/test_local_diagnostics.py`)**: redacts supported tokens (including overlapping patterns and Unicode escapes), preserves parser coordinates, separates runs, restricts file permissions, rejects symlink directories, and keeps partial audits usable when diagnostic writes fail. Non-object local responses are rejected before caching, and malformed legacy cache entries are re-investigated.
126
144
 
127
145
  ---
128
146
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "refuta"
7
- version = "0.0.2"
7
+ version = "0.0.3"
8
8
  description = "Refuta — Refutation-driven Agentic Code Security Platform for DGX Spark"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -1,3 +1,3 @@
1
1
  """Refuta — Refutation-driven Agentic Code Security Platform."""
2
2
 
3
- __version__ = "0.0.2"
3
+ __version__ = "0.0.3"
@@ -3,10 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ from contextlib import ExitStack
6
7
  from datetime import datetime, timezone
7
8
  import hashlib
8
9
  import json
10
+ import os
9
11
  from pathlib import Path
12
+ import secrets
10
13
  import sys
11
14
  import time
12
15
  from typing import Any
@@ -19,12 +22,15 @@ from .runners import (
19
22
  CodexRunner,
20
23
  FixtureRunner,
21
24
  LocalRunner,
25
+ LocalResponseError,
26
+ PROMPT_VERSION,
22
27
  RunnerRouter,
28
+ mask_diagnostic_text,
23
29
  )
24
30
  from .scanners import SECRET_PATTERNS, scan_snapshot
25
31
  from .schemas import AuditRunResult, EvidenceQuote, Finding, FindingStatus, RepoPolicy
26
32
  from .store import RefutaStore
27
- from .verifier import adjudicate_finding, verify_patch_in_sandbox, verify_quote_in_snapshot
33
+ from .verifier import adjudicate_finding, adjudicate_samples, verify_patch_in_sandbox, verify_quote_in_snapshot
28
34
 
29
35
 
30
36
  def render_markdown_report(result: AuditRunResult) -> str:
@@ -138,6 +144,55 @@ def render_sarif_report(result: AuditRunResult) -> dict[str, Any]:
138
144
  }
139
145
 
140
146
 
147
+ def _write_local_response_diagnostic(
148
+ out_path: Path,
149
+ run_id: str,
150
+ candidate_id: str,
151
+ rule_id: str,
152
+ sample_index: int,
153
+ error: LocalResponseError,
154
+ ) -> str:
155
+ """Write a private diagnostic through directory descriptors without following symlinks."""
156
+ payload = {
157
+ **error.diagnostic,
158
+ "run_id": run_id,
159
+ "candidate_id": candidate_id,
160
+ "rule_id": rule_id,
161
+ "sample_index": sample_index,
162
+ }
163
+ serialized = json.dumps(payload, indent=2)
164
+ # Validate the serialized file without changing the saved parse coordinates.
165
+ if mask_diagnostic_text(serialized) != serialized:
166
+ raise ValueError("Supported secret pattern remained in diagnostic")
167
+ diagnostic_root = out_path / "diagnostics"
168
+ diagnostic_root.mkdir(mode=0o700, exist_ok=True)
169
+ directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
170
+ filename = f"{candidate_id}-sample{sample_index}.json"
171
+ with ExitStack() as stack:
172
+ root_fd = os.open(diagnostic_root, directory_flags)
173
+ stack.callback(os.close, root_fd)
174
+ os.fchmod(root_fd, 0o700)
175
+ try:
176
+ os.mkdir(run_id, mode=0o700, dir_fd=root_fd)
177
+ except FileExistsError:
178
+ pass
179
+ run_fd = os.open(run_id, directory_flags, dir_fd=root_fd)
180
+ stack.callback(os.close, run_fd)
181
+ os.fchmod(run_fd, 0o700)
182
+ fd = os.open(
183
+ filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
184
+ 0o600, dir_fd=run_fd,
185
+ )
186
+ stack.callback(os.close, fd)
187
+ try:
188
+ with os.fdopen(fd, "w", encoding="utf-8", closefd=False) as diagnostic_file:
189
+ diagnostic_file.write(serialized)
190
+ except OSError:
191
+ os.unlink(filename, dir_fd=run_fd)
192
+ raise
193
+ return (Path("diagnostics") / run_id / filename).as_posix()
194
+
195
+
141
196
  def run_audit_pipeline(
142
197
  repo_path: str | Path,
143
198
  out_dir: str | Path,
@@ -147,8 +202,13 @@ def run_audit_pipeline(
147
202
  policy: RepoPolicy | None = None,
148
203
  patches_manifest_path: str | Path | None = None,
149
204
  only_files: set[str] | None = None,
205
+ samples: int | None = None,
150
206
  ) -> AuditRunResult:
151
- """Execute the full Refuta pipeline: Snapshot -> Scan -> Investigate & Refute -> Verify -> Store."""
207
+ """Run the audit pipeline with 3 samples for local and 1 for other runners by default."""
208
+ if samples is None:
209
+ samples = 3 if runner_name == "local" else 1
210
+ if samples < 1:
211
+ raise ValueError("samples must be >= 1")
152
212
  provider = RepoProvider()
153
213
  resolved_policy = policy or RepoPolicy(repo_id=Path(repo_path).resolve().name)
154
214
  snapshot = provider.create_snapshot(repo_path, repo_id=resolved_policy.repo_id, read_only=True)
@@ -178,6 +238,12 @@ def run_audit_pipeline(
178
238
  findings: list[Finding] = []
179
239
  model_calls = 0
180
240
  cache_hits = 0
241
+ # Allocate identity before investigations so every diagnostic belongs to a run.
242
+ # The random suffix separates back-to-back audits within the same second.
243
+ run_id = (
244
+ f"run-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}"
245
+ f"-{snapshot.snapshot_hash[:8]}-{secrets.token_hex(3)}"
246
+ )
181
247
 
182
248
  try:
183
249
  candidates = scan_snapshot(snapshot.snapshot_dir, only_files=only_files)
@@ -192,40 +258,58 @@ def run_audit_pipeline(
192
258
  rel: snapshot.file_hashes.get(rel, "")
193
259
  for rel in [cand.file_path, *cand.related_files]
194
260
  }
195
- cache_key = store.compute_cache_key(
196
- rule_id=cand.rule_id,
197
- file_path=cand.file_path,
198
- related_hashes=related_hashes,
199
- model=selected_runner.model_name,
200
- )
201
-
202
- cached_payload = store.get_cached_investigation(cache_key)
203
- if cached_payload is not None:
204
- agent_payload = cached_payload
205
- cache_hits += 1
206
- else:
207
- try:
208
- agent_payload = selected_runner.investigate_and_refute(
209
- snapshot.snapshot_dir,
210
- cand,
211
- resolved_policy,
212
- )
213
- model_calls += 1
214
- digest = hashlib.sha256(
215
- json.dumps(related_hashes, sort_keys=True).encode("utf-8")
216
- ).hexdigest()
217
- store.put_cached_investigation(
218
- cache_key=cache_key,
219
- rule_id=cand.rule_id,
220
- file_hashes_digest=digest,
221
- model=selected_runner.model_name,
222
- payload=agent_payload,
223
- )
224
- except Exception as exc:
225
- errors.append(f"{cand.candidate_id} ({cand.rule_id}): {exc}")
226
- agent_payload = None
261
+ payloads: list[dict[str, Any] | None] = []
262
+ for sample_index in range(samples):
263
+ cache_key = store.compute_cache_key(
264
+ rule_id=cand.rule_id,
265
+ file_path=cand.file_path,
266
+ related_hashes=related_hashes,
267
+ model=selected_runner.model_name,
268
+ prompt_version=PROMPT_VERSION,
269
+ sample_index=sample_index,
270
+ )
227
271
 
228
- finding = adjudicate_finding(snapshot.snapshot_dir, cand, agent_payload)
272
+ cached_payload = store.get_cached_investigation(cache_key)
273
+ # Older local runs could cache non-object JSON before failing
274
+ # adjudication. Re-investigate those entries instead of replaying them.
275
+ if selected_runner.runner_name == "local" and not isinstance(cached_payload, dict):
276
+ cached_payload = None
277
+ if cached_payload is not None:
278
+ agent_payload = cached_payload
279
+ cache_hits += 1
280
+ else:
281
+ try:
282
+ agent_payload = selected_runner.investigate_and_refute(
283
+ snapshot.snapshot_dir,
284
+ cand,
285
+ resolved_policy,
286
+ )
287
+ model_calls += 1
288
+ digest = hashlib.sha256(
289
+ json.dumps(related_hashes, sort_keys=True).encode("utf-8")
290
+ ).hexdigest()
291
+ store.put_cached_investigation(
292
+ cache_key=cache_key,
293
+ rule_id=cand.rule_id,
294
+ file_hashes_digest=digest,
295
+ model=selected_runner.model_name,
296
+ payload=agent_payload,
297
+ )
298
+ except Exception as exc:
299
+ error_message = f"{cand.candidate_id} ({cand.rule_id}) sample {sample_index}: {exc}"
300
+ if isinstance(exc, LocalResponseError):
301
+ try:
302
+ diagnostic_path = _write_local_response_diagnostic(
303
+ out_path, run_id, cand.candidate_id, cand.rule_id, sample_index, exc,
304
+ )
305
+ error_message += f"; diagnostic: {diagnostic_path}"
306
+ except (OSError, ValueError) as diagnostic_exc:
307
+ error_message += f"; diagnostic write failed ({type(diagnostic_exc).__name__})"
308
+ errors.append(mask_diagnostic_text(error_message))
309
+ agent_payload = None
310
+ payloads.append(agent_payload)
311
+
312
+ finding = adjudicate_samples(snapshot.snapshot_dir, cand, payloads)
229
313
  findings.append(finding)
230
314
 
231
315
  findings = store.apply_risk_exceptions(
@@ -256,8 +340,6 @@ def run_audit_pipeline(
256
340
 
257
341
  elapsed = time.perf_counter() - t0
258
342
  run_status = "completed" if not errors else "partial"
259
- run_id = f"run-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{snapshot.snapshot_hash[:8]}"
260
-
261
343
  run_result = AuditRunResult(
262
344
  run_id=run_id,
263
345
  repo_id=resolved_policy.repo_id,
@@ -491,6 +573,12 @@ def main(argv: list[str] | None = None) -> int:
491
573
  p_audit.add_argument("--fixture", help="Path to fixture JSON when --runner fixture is used")
492
574
  p_audit.add_argument("--state", help="Path to SQLite state DB")
493
575
  p_audit.add_argument("--patches", help="Optional path to patches manifest JSON for isolated patch verification")
576
+ p_audit.add_argument(
577
+ "--samples",
578
+ type=int,
579
+ default=None,
580
+ help="Independent investigations per candidate; strict-majority vote (default: 3 for local, 1 otherwise)",
581
+ )
494
582
  p_audit.add_argument(
495
583
  "--allow-external",
496
584
  action="store_true",
@@ -558,6 +646,7 @@ def main(argv: list[str] | None = None) -> int:
558
646
  state_db_path=args.state,
559
647
  policy=policy,
560
648
  patches_manifest_path=args.patches,
649
+ samples=args.samples,
561
650
  )
562
651
  summary = {
563
652
  "run_id": res.run_id,