diff-sentry 0.7.0__tar.gz → 0.7.1__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.
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.claude/rules/detect-setup.md +3 -2
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.claude/rules/indicators.md +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.claude/rules/rubric-and-eval.md +1 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.github/workflows/ci.yml +3 -3
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.github/workflows/release.yml +6 -6
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/AGENTS.md +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/CHANGELOG.md +13 -0
- diff_sentry-0.7.1/PKG-INFO +116 -0
- diff_sentry-0.7.1/README.md +95 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/action.yml +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/__init__.py +3 -3
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/actors.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/assemble.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/cli.py +13 -13
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/config.py +8 -8
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/deep_classify.py +23 -6
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/detect.py +9 -9
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/emit.py +6 -6
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/fetch_tool.py +4 -4
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/indicators.py +82 -56
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/ingest.py +22 -22
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/normalize.py +9 -9
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/response.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/rl_export.py +11 -11
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/rubric.py +16 -16
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/schema.py +11 -11
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/snapshot.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/watch.py +11 -11
- diff_sentry-0.7.1/docs/development.md +14 -0
- diff_sentry-0.7.1/docs/how-it-works.md +56 -0
- diff_sentry-0.7.1/docs/limits.md +17 -0
- diff_sentry-0.7.1/docs/watch.md +43 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/diff_sentry_eval/__init__.py +3 -3
- diff_sentry-0.7.1/eval/diff_sentry_eval/__main__.py +6 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/diff_sentry_eval/cli.py +3 -3
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/diff_sentry_eval/judge.py +35 -16
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/diff_sentry_eval/schema.py +7 -7
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/diff_sentry_eval/score.py +7 -7
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/diff_sentry_eval/taskset.py +6 -6
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/tests/conftest.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/tests/test_boundary.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/tests/test_cli.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/tests/test_judge.py +27 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/tests/test_schema.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/tests/test_score.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/tests/test_taskset.py +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/pyproject.toml +10 -10
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/README.md +1 -1
- diff_sentry-0.7.1/studio/diff_sentry_studio/__init__.py +1 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/diff_sentry_studio/app.py +17 -17
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/diff_sentry_studio/iterations.py +14 -14
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/diff_sentry_studio/live.py +16 -16
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/diff_sentry_studio/mapper.py +11 -11
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/pyproject.toml +3 -3
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/app.js +16 -16
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/index.html +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/replay-core.js +3 -3
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/run-core.js +9 -9
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/style.css +7 -7
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/trajectory.js +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/static-contract.test.js +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/test_app.py +3 -3
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/test_iterations.py +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/test_live.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/test_mapper.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/conftest.py +29 -4
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_assemble.py +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_cli.py +9 -9
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_cli_watch.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_config.py +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_deep_classify.py +37 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_detect.py +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_detection_quality.py +8 -8
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_export.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_fetch_tool.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_forward_offline.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_grounding.py +34 -10
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_incident_hackerbot_claw.py +6 -6
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_indicators.py +18 -18
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_ingest.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_injection_offline.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_inline_exec_and_bidi.py +0 -9
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_metadata_rules.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_provenance.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_response.py +4 -4
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_rubric.py +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_schema.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_subscription.py +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/uv.lock +5 -5
- diff_sentry-0.7.0/PKG-INFO +0 -214
- diff_sentry-0.7.0/README.md +0 -193
- diff_sentry-0.7.0/eval/diff_sentry_eval/__main__.py +0 -6
- diff_sentry-0.7.0/studio/diff_sentry_studio/__init__.py +0 -1
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.claude/rules/handoff.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.claude/rules/monitoring.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.claude/skills/release/SKILL.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.env.example +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.gitignore +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.python-version +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/LICENSE +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/VENDOR.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/__main__.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/dbutil.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/hosts.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/linewide.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/shelllex.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/skills/ci-and-workflows.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/skills/concealment-and-provenance.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/skills/developer-autorun.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/skills/llm-directed-text.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/skills/package-and-build-hooks.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/skills/runtime-droppers-and-exfil.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/skills/triage-a-change.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/README.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/pyproject.toml +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/taskset.example.json +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/DESIGN.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/replay-core.test.js +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/run-core.test.js +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/trajectory.test.js +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/__init__.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/corpus/change_corpus.json +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/corpus/hackerbot_claw_campaign.json +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/corpus/hackerbot_claw_incident.json +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/fixtures/known-malicious.diff +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/oracle/__init__.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/oracle/grammar.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/oracle/harness.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/oracle/lexrunner.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/oracle/programs.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/oracle/runner.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_actors.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_adversarial_input.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_campaign_replay.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_dogfood_gate.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_emit.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_enclosure.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_file_scope.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_file_structure.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_gate_edges.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_hosts.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_ingest_metadata.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_linewide.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_normalize.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_oracle.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_oracle_harness.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_shelllex.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_snapshot.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_watch.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_watch_pool.py +0 -0
|
@@ -11,9 +11,10 @@ paths:
|
|
|
11
11
|
# Model roles, auth, and run failures
|
|
12
12
|
|
|
13
13
|
- **Failures name themselves.** With `max_retries=1` (set in `detect.setup`), a non-retryable LM error (auth / billing / configuration / unsupported model) escapes after one attempt as the raw `dspy.LMError` subclass. An `RLMTaskError` means the single attempt ended in an adapter parse failure, an invalid result, or a retryable endpoint fault (timeout / 5xx / transport). `cli.run` turns both into a `status=failed` response. Check the endpoint before suspecting the schema.
|
|
14
|
-
- **An empty answer at the cap is a planner that thought too long, not one that cannot follow the format.** A reasoning planner whose call ends `finish_reason=length` with empty content fails the run with `AdapterParseError
|
|
14
|
+
- **An empty answer at the cap is a planner that thought too long, not one that cannot follow the format.** A reasoning planner whose call ends `finish_reason=length` with empty content fails the run with `AdapterParseError`. Its message starts "This reply carried no content" when the reply quoted is the promoted reasoning channel, and quotes any other reply as the model wrote it; the raw call's `finish_reason` tells a cut at the cap from a reply that ignored the format. The fix is a thinking budget below `DS_PLANNER_MAX_TOKENS` through `DS_ROOT_LM_KWARGS`, never a whole-run retry or a different adapter.
|
|
15
15
|
- **`DS_ROOT_LM_KWARGS` / `DS_SUB_LM_KWARGS` are aliases of rlm-harness's `RLM_MAIN_LM_KWARGS` / `RLM_SUB_LM_KWARGS`.** `setup` builds the kit's config itself and never calls the kit's `from_env`, the only reader of those two; `DetectConfig.from_env` reads both names and refuses them set to different values. It also refuses the keys the kit owns (model, endpoint, timeout), so a bad value stops the process rather than failing every run. The analyst's parameters never reach the classifier, which builds its own request.
|
|
16
|
-
- **`DS_CLASSIFIER_LM_KWARGS` is the classifier's own**, over its `temperature=0.1`. `from_env` refuses the keys its settings own (model, messages, stream, n, timeout, max_tokens, max_completion_tokens) and, when the `classify` extra is installed, any key the OpenAI client does not take
|
|
16
|
+
- **`DS_CLASSIFIER_LM_KWARGS` is the classifier's own**, over its `temperature=0.1`. `from_env` refuses the keys its settings own (model, messages, stream, n, timeout, max_tokens, max_completion_tokens) and, when the `classify` extra is installed, any key the OpenAI client does not take, so a bad value stops the process rather than failing every call.
|
|
17
17
|
- **A classifier reply cut at `max_tokens` is an unusable reply that names the cut** (`deep_classify._CutReply`, read by the validator), not an endpoint error. The same request is cut the same way, so it is not re-sent, and a classifier that keeps thinking through its budget trips the circuit breaker. A reply cut after a whole verdict still counts. The tool_call records `cut: true`. The fix is a thinking budget in `DS_CLASSIFIER_LM_KWARGS` (raising `DS_CLASSIFIER_MAX_TOKENS` alone can turn a slow model's cut into a timeout).
|
|
18
|
+
- **An error status that repeats is not re-sent** (`deep_classify.is_fast_fail_http`, passed to `make_model_tool` as `is_fast_fail`): a 400, 401, 403, 404 or 422 from the classifier's endpoint is an endpoint error after one call, and it counts toward the circuit breaker, so a wrong model name, key or request stops being called. A timeout, a transport error, a 408, 409, 429 or 5xx keeps the transient retry and never counts. The eval judge holds its own copy without 400 and 422: its breaker spans a whole batch, and a status one oversized change earns there would leave the later runs unscored.
|
|
18
19
|
- **Subscription auth is a lazy, opt-in branch.** A role whose model is `claude-agent-sdk/<id>` runs on rlm-harness's `ClaudeAgentLM`, imported only inside `detect._maybe_subscription_lm` and never from `__init__.py`, so `import diff_sentry` stays dspy-free and a proxy-only install never touches the SDK.
|
|
19
20
|
- **The classifier never runs on the subscription.** It is a separate OpenAI-compatible client (`deep_classify._selfclassify_chat`), so `config.from_env` rejects a `claude-agent-sdk/` classifier model, whether set explicitly or inherited from `DS_SUB_LM`. Mixed auth across roles is by design.
|
|
@@ -44,7 +44,7 @@ paths:
|
|
|
44
44
|
|
|
45
45
|
## `curl-pipe-shell`
|
|
46
46
|
|
|
47
|
-
A download piped into a shell or an interpreter is `critical`. It has three forms that are judged separately: the pipe, the command substitution and PowerShell's. In prose (a `title`, `body` or `comment`, or a documentation file: `.md .mdx .markdown .rst .adoc .ipynb .html .htm`) a documented installer is `medium`. Code files (`.sh`, `.go`, `.yml`, a Dockerfile, `requirements.txt`) and branches are never softened. The segment kind decides, never a label. The planner's `scan_indicators` applies the same softening to the region it is given.
|
|
47
|
+
A download piped into a shell or an interpreter is `critical`. It has three forms that are judged separately: the pipe, the command substitution and PowerShell's. In prose (a `title`, `body` or `comment`, or a documentation file: `.md .mdx .markdown .rst .adoc .ipynb .html .htm`) a documented installer is `medium`. Code files (`.sh`, `.go`, `.yml`, a Dockerfile, `requirements.txt`) and branches are never softened. The segment kind decides, never a label. The planner's `scan_indicators` applies the same softening to the region it is given. A region with added lines and a `- ` line but no hunk or file header could be a patch slice or a list, so it is not softened, unless every line of it is a whole line, diff marker and all, of a documentation file's patch (`_Change.patch_of`): it is then read as the baseline reads that file.
|
|
48
48
|
|
|
49
49
|
### What it matches
|
|
50
50
|
|
|
@@ -14,6 +14,7 @@ paths:
|
|
|
14
14
|
|
|
15
15
|
- `rl_export.rubric_signal` attaches the ATLAS 4-category (TF/TA/TG/PA) decomposition as LABELS: a fixed, no-LLM skeleton in `run_start` meta (`rubric_to_meta(default_rubric())`, one criterion per category, since the task is constant; there is no `generate_rubric` path) plus deterministic per-criterion `criteria_facts`.
|
|
16
16
|
- The facts are a re-lens over `run_labels` / `run_metrics`, not a second derivation: `rubric.trace_facts` reuses them, so a criterion's `observed` cannot drift from what a trainer reads. `_CATEGORY_LENS` maps TF↔(verdict / signal / hit_iteration_cap), TA↔(scan / deep_classify / analyst / fetch / skill counts + circuit-breaks + analyst failures), TG↔(indicator_count / max_indicator_severity / signal / cited_unknown), PA↔(verdict / cited_unknown).
|
|
17
|
+
- `deep_classify_circuit_breaks` counts the tool_calls the breaker short-circuited. From rlm-harness 1.22.0 a repeating error status (`is_fast_fail_http`) counts toward the breaker as an unusable reply does, so a break in a newer trace can mean a misconfigured classifier endpoint as well as a thrashing planner; the status in the run's endpoint-error tool_calls (`Error code: 401`) tells them apart, since a 429, a 5xx or a timeout records one too and does not count.
|
|
17
18
|
- `CriterionFact` has no score/met field; the trainer scores. Never add a reward/score/met field to any rubric type.
|
|
18
19
|
- `rubric.py` is a dspy-free leaf: it imports only `.schema` at top, and its `rl_export` reuse is a function-level import (that path is dspy-free too), so `response.py` can import it at top.
|
|
19
20
|
- `response._rubric` surfaces it as `DetectionResponse.rubric` (`RubricReport`); the studio shows it as a card tagged `labels — not a score` that adds no judgement. Keep the card legacy-guarded: a response without a `rubric` renders nothing.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
name: CI
|
|
2
2
|
|
|
3
|
-
# Offline suite only: no live LLM, no network calls in tests, no Deno sandbox
|
|
3
|
+
# Offline suite only: no live LLM, no network calls in tests, no Deno sandbox: dspy-bearing paths use
|
|
4
4
|
# DummyLM / rlm-harness's ScriptedInterpreter, transports are injected fakes, and the corpus pins the
|
|
5
5
|
# indicator suite's hit/miss behavior (see AGENTS.md "Verify"). `uv sync` resolves rlm-harness from PyPI
|
|
6
6
|
# at the exact version pinned in pyproject.toml / uv.lock.
|
|
@@ -9,7 +9,7 @@ on:
|
|
|
9
9
|
branches: [main]
|
|
10
10
|
pull_request:
|
|
11
11
|
|
|
12
|
-
# Least privilege: this workflow only checks out code and runs tests/lint
|
|
12
|
+
# Least privilege: this workflow only checks out code and runs tests/lint; it never writes. Specifying
|
|
13
13
|
# `permissions:` also drops every unlisted scope to `none`, so a compromised dep/action on an untrusted
|
|
14
14
|
# fork PR can't use the token to push, tag, or open issues.
|
|
15
15
|
permissions:
|
|
@@ -62,7 +62,7 @@ jobs:
|
|
|
62
62
|
for f in studio/tests/*.test.js; do echo "▸ $f"; node "$f"; done
|
|
63
63
|
|
|
64
64
|
# Dogfood: this repo's own CI runs the ACTION this repo publishes (`uses: ./`), not a copy of it.
|
|
65
|
-
# So every PR here is also an integration test of the thing users install
|
|
65
|
+
# So every PR here is also an integration test of the thing users install: a drifted action.yml
|
|
66
66
|
# fails our own build before it reaches anyone's Marketplace install.
|
|
67
67
|
#
|
|
68
68
|
# DELIBERATELY the deterministic scan and not the full `classify` pipeline. `classify` needs DS_*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
name: Release
|
|
2
2
|
|
|
3
|
-
# Publish to PyPI via Trusted Publishing (OIDC)
|
|
3
|
+
# Publish to PyPI via Trusted Publishing (OIDC), with NO API token. INERT until a GitHub Release is
|
|
4
4
|
# published, and needs one-time setup before the first release:
|
|
5
5
|
# 1. On PyPI, add a Trusted Publisher for project `diff-sentry`:
|
|
6
6
|
# owner = qazbnm456, repo = diff-sentry, workflow = release.yml, environment = pypi
|
|
@@ -11,7 +11,7 @@ name: Release
|
|
|
11
11
|
# consumers pin in `uses: qazbnm456/diff-sentry@vX`. So cutting a release ships the package AND the
|
|
12
12
|
# Action; keep the README's quickstart ref in step with the tag you publish.
|
|
13
13
|
#
|
|
14
|
-
# Supply-chain: the two third-party actions are SHA-pinned
|
|
14
|
+
# Supply-chain: the two third-party actions are SHA-pinned: setup-uv, and (highest blast radius, it
|
|
15
15
|
# uploads to PyPI) pypi-publish. Bump a SHA and its trailing version comment together. GitHub-owned
|
|
16
16
|
# checkout/*-artifact stay on major tags.
|
|
17
17
|
|
|
@@ -34,7 +34,7 @@ jobs:
|
|
|
34
34
|
- name: Build sdist + wheel
|
|
35
35
|
run: uv build
|
|
36
36
|
|
|
37
|
-
# Verify the ARTIFACT, not the checkout
|
|
37
|
+
# Verify the ARTIFACT, not the checkout: a wheel can build cleanly and still be unusable. Without
|
|
38
38
|
# a `[project.scripts]` entry point, for example, `uvx diff-sentry` (the exact command the GitHub
|
|
39
39
|
# Action runs) cannot work, and nothing in the source tree says so. Installing the built wheel into
|
|
40
40
|
# a clean environment and running it is what catches that.
|
|
@@ -75,7 +75,7 @@ jobs:
|
|
|
75
75
|
exit 1
|
|
76
76
|
fi
|
|
77
77
|
|
|
78
|
-
- uses: actions/upload-artifact@
|
|
78
|
+
- uses: actions/upload-artifact@v7
|
|
79
79
|
with:
|
|
80
80
|
name: dist
|
|
81
81
|
path: dist/
|
|
@@ -87,14 +87,14 @@ jobs:
|
|
|
87
87
|
# would fire this job and ask PyPI to publish `diff-sentry` over OIDC. The Trusted Publisher binding
|
|
88
88
|
# (owner+repo+workflow+environment) would reject it, but that is a rejected publish attempt against
|
|
89
89
|
# OUR project, plus a probe surface and a confusing red run in someone else's repo. `build` above is
|
|
90
|
-
# deliberately NOT guarded
|
|
90
|
+
# deliberately NOT guarded: it only builds and tests an artifact, and a fork verifying its own
|
|
91
91
|
# build is fine.
|
|
92
92
|
if: github.repository == 'qazbnm456/diff-sentry'
|
|
93
93
|
environment: pypi
|
|
94
94
|
permissions:
|
|
95
95
|
id-token: write # OIDC token for PyPI Trusted Publishing
|
|
96
96
|
steps:
|
|
97
|
-
- uses: actions/download-artifact@
|
|
97
|
+
- uses: actions/download-artifact@v8
|
|
98
98
|
with:
|
|
99
99
|
name: dist
|
|
100
100
|
path: dist/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# diff-sentry: agent guide
|
|
2
2
|
|
|
3
|
-
A BewAIre-style malicious-change detector built on `rlm-harness`. It classifies ONE GitHub change (PR / issue / push) for malicious intent: the change is UNTRUSTED DATA held in a sandboxed REPL, the planner SUBMITs a judgement-only verdict, and the deterministic indicator EVIDENCE is unioned on read into a SIEM signal. `README.md`
|
|
3
|
+
A BewAIre-style malicious-change detector built on `rlm-harness`. It classifies ONE GitHub change (PR / issue / push) for malicious intent: the change is UNTRUSTED DATA held in a sandboxed REPL, the planner SUBMITs a judgement-only verdict, and the deterministic indicator EVIDENCE is unioned on read into a SIEM signal. `README.md` is the user-facing overview and `docs/` holds the pipeline and the honest limits; rlm-harness's "Building a consumer" is the extension contract this project lives within.
|
|
4
4
|
|
|
5
5
|
rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, locked in `uv.lock`). Overlay `uv pip install -e ../rlm-harness` only while co-developing the kit, and bump the pin once the fix ships.
|
|
6
6
|
|
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to diff-sentry, a BewAIre-style detector that classifies one GitHub change (PR, issue or push) for malicious intent on [`rlm-harness`](https://github.com/qazbnm456/rlm-harness).
|
|
4
4
|
|
|
5
|
+
## 0.7.1
|
|
6
|
+
|
|
7
|
+
Upgrade from 0.7.0: `rlm-harness==1.22.0`. The GitHub Action's scan is unchanged; the model pipeline (`pr`, `issue`, `classify`, `watch`) raises fewer false signals on documentation changes and stops calling a misconfigured classifier sooner. Nothing needs configuring.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- **A classifier endpoint that answers 400, 401, 403, 404 or 422 is called once**, not retried, and counts toward the circuit breaker, so a wrong model name, key or request stops being called. Timeouts, 429 and 5xx are retried as before. The eval judge does the same for 401, 403 and 404.
|
|
11
|
+
- **A planner run that fails on an unparseable reply quotes the reply as the model wrote it**, and says so when the reply carried only reasoning.
|
|
12
|
+
- **`rlm-harness==1.22.0`.**
|
|
13
|
+
- **The README covers using diff-sentry**; how it works, the watcher, the known limits and development moved to `docs/`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **A planner re-scan of a slice of a documentation patch is softened as the scan of that file is**, so a README change that adds a documented installer and removes an indented line no longer signals as `critical`.
|
|
17
|
+
|
|
5
18
|
## 0.7.0
|
|
6
19
|
|
|
7
20
|
Upgrade from 0.6.0: `rlm-harness==1.20.0` and `dspy` 3.4.1. The signal now reads only the evidence the planner grounded in the change, and several rules read lower or higher than before, so a SIEM that alerts on severity sees fewer false signals and some new ones (see **Changed**). Nothing needs configuring; the new `DS_*` settings are optional.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: diff-sentry
|
|
3
|
+
Version: 0.7.1
|
|
4
|
+
Summary: Catch malicious pull requests before they merge: scan a diff for supply-chain attack patterns with deterministic evidence a model cannot suppress.
|
|
5
|
+
Author: Boik Su
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: dspy,malicious-code-detection,prompt-injection,rlm,security,siem,supply-chain-security
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: pydantic>=2.11.0
|
|
12
|
+
Requires-Dist: rlm-harness==1.22.0
|
|
13
|
+
Provides-Extra: classify
|
|
14
|
+
Requires-Dist: openai>=1.0; extra == 'classify'
|
|
15
|
+
Provides-Extra: observe
|
|
16
|
+
Requires-Dist: langfuse>=4.6.1; extra == 'observe'
|
|
17
|
+
Requires-Dist: openinference-instrumentation-dspy>=0.1.36; extra == 'observe'
|
|
18
|
+
Provides-Extra: subscription
|
|
19
|
+
Requires-Dist: claude-agent-sdk>=0.1.60; extra == 'subscription'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# diff-sentry
|
|
23
|
+
|
|
24
|
+
**Catch a malicious pull request before you merge it.**
|
|
25
|
+
|
|
26
|
+
diff-sentry reads a change the way an attacker hopes you won't: it treats the diff as untrusted data, looks for the shapes that real supply-chain attacks use, and reports evidence rather than an opinion.
|
|
27
|
+
|
|
28
|
+
The fastest way to use it is as a GitHub Action. It needs no API key, no model, and no network access, so it runs on fork pull requests under the read-only token they already get.
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
# .github/workflows/diff-sentry.yml
|
|
32
|
+
name: diff-sentry
|
|
33
|
+
on: pull_request
|
|
34
|
+
|
|
35
|
+
permissions:
|
|
36
|
+
contents: read
|
|
37
|
+
|
|
38
|
+
jobs:
|
|
39
|
+
scan:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@v7
|
|
43
|
+
with:
|
|
44
|
+
fetch-depth: 0 # the scan diffs against the PR base
|
|
45
|
+
- uses: qazbnm456/diff-sentry@v0.7.1
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That is the whole setup. The job fails when an indicator at or above `high` fires.
|
|
49
|
+
|
|
50
|
+
## What it catches
|
|
51
|
+
|
|
52
|
+
| Family | Examples |
|
|
53
|
+
|---|---|
|
|
54
|
+
| Workflow configuration | `pull_request_target` that checks out the PR head (the pwn-request that broke AsyncAPI) by `ref:`, by a hand-rolled `git fetch`, or by opting back in with `allow-unsafe-pr-checkout: true`; plus `permissions: write-all` and CODEOWNERS reassignment |
|
|
55
|
+
| Obfuscation | base64 that decodes to a shell payload, `_0x…` machine-mangled JavaScript, high-entropy blobs |
|
|
56
|
+
| Execution | pipe-to-shell installs, `${IFS}` space-evasion, detached child processes, inline `node -e`, fetch-to-disk droppers |
|
|
57
|
+
| Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
|
|
58
|
+
| Diff-presentation evasion | payloads shoved past the viewport by a long whitespace run, bidi-override and zero-width characters |
|
|
59
|
+
| Provenance | forged `[bot]` identities, bot-mimicking new accounts, accounts created days before the event, profiles that describe an autonomous agent, unsigned or identity-mismatched commits, a fork deleted after the PR |
|
|
60
|
+
| Author-controlled channels | a PR branch name carrying shell syntax, slash-command comments that fire `issue_comment` workflows |
|
|
61
|
+
| Campaign memory (`watch` only) | an account or payload host already flagged in another repository, an edit that removes a flagged payload, a burst of changes from a new account |
|
|
62
|
+
| Prompt injection | instructions aimed at an LLM reviewer or triage workflow |
|
|
63
|
+
|
|
64
|
+
A plain workflow edit, or a `pull_request_target` on its own, is `medium`, below the failure threshold, so a benign workflow PR does not break your build. Deleted lines are not scanned, so removing a payload is not flagged. [How it works](docs/how-it-works.md#severities-are-tuned-against-noise) explains the tuning.
|
|
65
|
+
|
|
66
|
+
## Inputs
|
|
67
|
+
|
|
68
|
+
| Input | Default | Purpose |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `fail-on` | `high` | Severity that fails the build (`info`/`low`/`medium`/`high`/`critical`). |
|
|
71
|
+
| `report-only-paths` | none | Newline-separated globs scanned but never gated. For files whose job is to carry attack patterns: rule bodies, security fixtures, prompt templates, docs that tabulate payloads. |
|
|
72
|
+
| `base-sha` | derived | Override the commit to diff against. |
|
|
73
|
+
| `version` | matches the action tag | The diff-sentry release to install from PyPI. `local` installs from the current checkout instead, which is how this repo scans its own PRs with the rules in that PR. |
|
|
74
|
+
|
|
75
|
+
Outputs: `failed`, `hit-count`, `max-severity`, and `report` (a JSON path).
|
|
76
|
+
|
|
77
|
+
## Do not reach for `pull_request_target`
|
|
78
|
+
|
|
79
|
+
Sooner or later you will want the scan to post its results as a PR comment, and a `pull_request` job on a fork PR has a read-only token. The tempting fix is `pull_request_target`, which is the exact misconfiguration that opened the AsyncAPI "Miasma" compromise, and which this action reports as `critical`.
|
|
80
|
+
|
|
81
|
+
Do it the safe way instead: keep the scan on `pull_request` with no secrets, upload the `report` as an artifact, and post it from a separate `workflow_run` job that runs the base branch's code and never checks out the PR head. diff-sentry's own rules make the same distinction, so the safe shape does not trip them.
|
|
82
|
+
|
|
83
|
+
## Evidence a model cannot suppress
|
|
84
|
+
|
|
85
|
+
An LLM reviewer can be argued out of its conclusion by the diff it reads. The Action runs no model at all. In the full pipeline, the rules record their hits before any model takes a turn, and the alert is derived from those hits, so a prompt injection can skew a verdict but cannot remove the evidence ([details](docs/how-it-works.md#evidence-a-model-cannot-suppress)).
|
|
86
|
+
|
|
87
|
+
## Beyond the Action
|
|
88
|
+
|
|
89
|
+
https://github.com/user-attachments/assets/d200aee8-263b-483e-ad55-f90ae69f3ab0
|
|
90
|
+
|
|
91
|
+
_The studio console: paste a real payload, watch the detection stream live, then read the evidence-framed verdict._
|
|
92
|
+
|
|
93
|
+
If you operate the infrastructure, or maintain a project where a wrong call is expensive, the rest of the system runs locally with a model endpoint you choose:
|
|
94
|
+
|
|
95
|
+
- **Deep classification**: a reasoning model reads a PR, issue or push in a sandbox and returns a verdict next to the evidence. See [How it works](docs/how-it-works.md).
|
|
96
|
+
- **The studio**: a local console that streams a run as it happens. See [`studio/README.md`](studio/README.md).
|
|
97
|
+
- **Watch**: monitors a list of repositories, alerts the moment the rules fire, and remembers the accounts and payload hosts it has seen. See [Watching a watchlist](docs/watch.md).
|
|
98
|
+
- **Trajectories for training**: every run is recorded, and `export` turns the traces into a reward-free dataset for fine-tuning a smaller planner. See [The full pipeline](docs/how-it-works.md#the-full-pipeline).
|
|
99
|
+
|
|
100
|
+
None of this is needed for the Action, and none of it sends your code anywhere you did not configure yourself.
|
|
101
|
+
|
|
102
|
+
## Limits
|
|
103
|
+
|
|
104
|
+
A strong prompt injection can skew a model's verdict, though never the evidence. Some pipe-to-shell shapes, and a secret that reaches a network call across lines, are missed. An external watcher cannot stop an attack that fires the moment a PR opens. [Honest limits](docs/limits.md) lists the known gaps.
|
|
105
|
+
|
|
106
|
+
## Documentation
|
|
107
|
+
|
|
108
|
+
- [How it works](docs/how-it-works.md): severity tuning, the evidence union, the full pipeline, running it locally, and the incidents the tests reconstruct.
|
|
109
|
+
- [Watching a watchlist](docs/watch.md): the monitor, its alerts, its archive, and what the account dossier holds.
|
|
110
|
+
- [Honest limits](docs/limits.md): what the rules and the pipeline do not see.
|
|
111
|
+
- [Development](docs/development.md): the test suites and CI.
|
|
112
|
+
- [CHANGELOG](CHANGELOG.md)
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT. Built on [`rlm-harness`](https://github.com/qazbnm456/rlm-harness).
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# diff-sentry
|
|
2
|
+
|
|
3
|
+
**Catch a malicious pull request before you merge it.**
|
|
4
|
+
|
|
5
|
+
diff-sentry reads a change the way an attacker hopes you won't: it treats the diff as untrusted data, looks for the shapes that real supply-chain attacks use, and reports evidence rather than an opinion.
|
|
6
|
+
|
|
7
|
+
The fastest way to use it is as a GitHub Action. It needs no API key, no model, and no network access, so it runs on fork pull requests under the read-only token they already get.
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
# .github/workflows/diff-sentry.yml
|
|
11
|
+
name: diff-sentry
|
|
12
|
+
on: pull_request
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
scan:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
|
+
with:
|
|
23
|
+
fetch-depth: 0 # the scan diffs against the PR base
|
|
24
|
+
- uses: qazbnm456/diff-sentry@v0.7.1
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
That is the whole setup. The job fails when an indicator at or above `high` fires.
|
|
28
|
+
|
|
29
|
+
## What it catches
|
|
30
|
+
|
|
31
|
+
| Family | Examples |
|
|
32
|
+
|---|---|
|
|
33
|
+
| Workflow configuration | `pull_request_target` that checks out the PR head (the pwn-request that broke AsyncAPI) by `ref:`, by a hand-rolled `git fetch`, or by opting back in with `allow-unsafe-pr-checkout: true`; plus `permissions: write-all` and CODEOWNERS reassignment |
|
|
34
|
+
| Obfuscation | base64 that decodes to a shell payload, `_0x…` machine-mangled JavaScript, high-entropy blobs |
|
|
35
|
+
| Execution | pipe-to-shell installs, `${IFS}` space-evasion, detached child processes, inline `node -e`, fetch-to-disk droppers |
|
|
36
|
+
| Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
|
|
37
|
+
| Diff-presentation evasion | payloads shoved past the viewport by a long whitespace run, bidi-override and zero-width characters |
|
|
38
|
+
| Provenance | forged `[bot]` identities, bot-mimicking new accounts, accounts created days before the event, profiles that describe an autonomous agent, unsigned or identity-mismatched commits, a fork deleted after the PR |
|
|
39
|
+
| Author-controlled channels | a PR branch name carrying shell syntax, slash-command comments that fire `issue_comment` workflows |
|
|
40
|
+
| Campaign memory (`watch` only) | an account or payload host already flagged in another repository, an edit that removes a flagged payload, a burst of changes from a new account |
|
|
41
|
+
| Prompt injection | instructions aimed at an LLM reviewer or triage workflow |
|
|
42
|
+
|
|
43
|
+
A plain workflow edit, or a `pull_request_target` on its own, is `medium`, below the failure threshold, so a benign workflow PR does not break your build. Deleted lines are not scanned, so removing a payload is not flagged. [How it works](docs/how-it-works.md#severities-are-tuned-against-noise) explains the tuning.
|
|
44
|
+
|
|
45
|
+
## Inputs
|
|
46
|
+
|
|
47
|
+
| Input | Default | Purpose |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `fail-on` | `high` | Severity that fails the build (`info`/`low`/`medium`/`high`/`critical`). |
|
|
50
|
+
| `report-only-paths` | none | Newline-separated globs scanned but never gated. For files whose job is to carry attack patterns: rule bodies, security fixtures, prompt templates, docs that tabulate payloads. |
|
|
51
|
+
| `base-sha` | derived | Override the commit to diff against. |
|
|
52
|
+
| `version` | matches the action tag | The diff-sentry release to install from PyPI. `local` installs from the current checkout instead, which is how this repo scans its own PRs with the rules in that PR. |
|
|
53
|
+
|
|
54
|
+
Outputs: `failed`, `hit-count`, `max-severity`, and `report` (a JSON path).
|
|
55
|
+
|
|
56
|
+
## Do not reach for `pull_request_target`
|
|
57
|
+
|
|
58
|
+
Sooner or later you will want the scan to post its results as a PR comment, and a `pull_request` job on a fork PR has a read-only token. The tempting fix is `pull_request_target`, which is the exact misconfiguration that opened the AsyncAPI "Miasma" compromise, and which this action reports as `critical`.
|
|
59
|
+
|
|
60
|
+
Do it the safe way instead: keep the scan on `pull_request` with no secrets, upload the `report` as an artifact, and post it from a separate `workflow_run` job that runs the base branch's code and never checks out the PR head. diff-sentry's own rules make the same distinction, so the safe shape does not trip them.
|
|
61
|
+
|
|
62
|
+
## Evidence a model cannot suppress
|
|
63
|
+
|
|
64
|
+
An LLM reviewer can be argued out of its conclusion by the diff it reads. The Action runs no model at all. In the full pipeline, the rules record their hits before any model takes a turn, and the alert is derived from those hits, so a prompt injection can skew a verdict but cannot remove the evidence ([details](docs/how-it-works.md#evidence-a-model-cannot-suppress)).
|
|
65
|
+
|
|
66
|
+
## Beyond the Action
|
|
67
|
+
|
|
68
|
+
https://github.com/user-attachments/assets/d200aee8-263b-483e-ad55-f90ae69f3ab0
|
|
69
|
+
|
|
70
|
+
_The studio console: paste a real payload, watch the detection stream live, then read the evidence-framed verdict._
|
|
71
|
+
|
|
72
|
+
If you operate the infrastructure, or maintain a project where a wrong call is expensive, the rest of the system runs locally with a model endpoint you choose:
|
|
73
|
+
|
|
74
|
+
- **Deep classification**: a reasoning model reads a PR, issue or push in a sandbox and returns a verdict next to the evidence. See [How it works](docs/how-it-works.md).
|
|
75
|
+
- **The studio**: a local console that streams a run as it happens. See [`studio/README.md`](studio/README.md).
|
|
76
|
+
- **Watch**: monitors a list of repositories, alerts the moment the rules fire, and remembers the accounts and payload hosts it has seen. See [Watching a watchlist](docs/watch.md).
|
|
77
|
+
- **Trajectories for training**: every run is recorded, and `export` turns the traces into a reward-free dataset for fine-tuning a smaller planner. See [The full pipeline](docs/how-it-works.md#the-full-pipeline).
|
|
78
|
+
|
|
79
|
+
None of this is needed for the Action, and none of it sends your code anywhere you did not configure yourself.
|
|
80
|
+
|
|
81
|
+
## Limits
|
|
82
|
+
|
|
83
|
+
A strong prompt injection can skew a model's verdict, though never the evidence. Some pipe-to-shell shapes, and a secret that reaches a network call across lines, are missed. An external watcher cannot stop an attack that fires the moment a PR opens. [Honest limits](docs/limits.md) lists the known gaps.
|
|
84
|
+
|
|
85
|
+
## Documentation
|
|
86
|
+
|
|
87
|
+
- [How it works](docs/how-it-works.md): severity tuning, the evidence union, the full pipeline, running it locally, and the incidents the tests reconstruct.
|
|
88
|
+
- [Watching a watchlist](docs/watch.md): the monitor, its alerts, its archive, and what the account dossier holds.
|
|
89
|
+
- [Honest limits](docs/limits.md): what the rules and the pipeline do not see.
|
|
90
|
+
- [Development](docs/development.md): the test suites and CI.
|
|
91
|
+
- [CHANGELOG](CHANGELOG.md)
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT. Built on [`rlm-harness`](https://github.com/qazbnm456/rlm-harness).
|
|
@@ -44,7 +44,7 @@ inputs:
|
|
|
44
44
|
# KEEP IN STEP WITH [project].version. release.yml fails the build if this drifts
|
|
45
45
|
# from the tag being released, because a stale default here would silently run an
|
|
46
46
|
# old scanner under a new action tag.
|
|
47
|
-
default: '0.7.
|
|
47
|
+
default: '0.7.1'
|
|
48
48
|
|
|
49
49
|
outputs:
|
|
50
50
|
failed:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""diff-sentry
|
|
1
|
+
"""diff-sentry: classify a GitHub change for malicious intent, as a traced RLM harness.
|
|
2
2
|
|
|
3
3
|
A downstream *consumer* of rlm-harness (an exact PyPI pin) reproducing Datadog's BewAIre defense shape:
|
|
4
4
|
ingest a GitHub PR/issue/push → analyze the diff as UNTRUSTED data in a sandboxed REPL → produce a
|
|
@@ -80,10 +80,10 @@ __all__ = [
|
|
|
80
80
|
"screen",
|
|
81
81
|
]
|
|
82
82
|
|
|
83
|
-
__version__ = "0.7.
|
|
83
|
+
__version__ = "0.7.1"
|
|
84
84
|
|
|
85
85
|
|
|
86
|
-
def __getattr__(name: str): # PEP 562
|
|
86
|
+
def __getattr__(name: str): # PEP 562: defer the dspy import to first use
|
|
87
87
|
if name in ("ClassifyChange", "setup"):
|
|
88
88
|
from . import detect
|
|
89
89
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Host-side actor store
|
|
1
|
+
"""Host-side actor store: who is behind a change, and what else they did.
|
|
2
2
|
|
|
3
3
|
A per-change classifier sees one PR. The campaigns worth catching span many: hackerbot-claw hit seven
|
|
4
4
|
repositories in ten days from one account, with one payload host. The defender of each repository saw one
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
"""Assemble the canonical verdict from the trace
|
|
1
|
+
"""Assemble the canonical verdict from the trace: the read-time step that makes the EVIDENCE a fact.
|
|
2
2
|
|
|
3
3
|
`assemble_verdict(verdict, events)` attaches the deterministic fields the SYSTEM owns to the planner's
|
|
4
4
|
JUDGEMENT (`ChangeVerdict`, no hits): the UNION of ALL indicator hits from the trace (baseline ∪ every
|
|
5
|
-
`scan_indicators` tool call
|
|
5
|
+
`scan_indicators` tool call; MF3), the derived max severity, whether to SIGNAL, and any indicator ids
|
|
6
6
|
the planner cited that match no recorded hit (a fabrication tell).
|
|
7
7
|
|
|
8
8
|
The `signal` decision is DETERMINISTIC and partly independent of the planner: it fires if the planner's
|
|
9
9
|
verdict is in `emit_on` OR a high/critical deterministic indicator fired. So a false-benign self-report
|
|
10
|
-
cannot suppress hard evidence from reaching the SIEM
|
|
10
|
+
cannot suppress hard evidence from reaching the SIEM: the planner can skew the verdict, never hide the
|
|
11
11
|
indicators. (This is deterministic reduction over facts, NOT a model judgement smuggled into the read
|
|
12
|
-
path
|
|
12
|
+
path: the verdict tier stays the planner's; only the evidence-driven floor is ours.)
|
|
13
13
|
|
|
14
|
-
This runs everywhere the result is consumed
|
|
14
|
+
This runs everywhere the result is consumed: the live path (cli), re-render, and rl_export, so labels
|
|
15
15
|
read facts too. A trace missing a newer field still reads: pydantic ignores unknown keys, and hits
|
|
16
16
|
re-source from the trace.
|
|
17
17
|
|
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
python -m diff_sentry archive verify # re-hash the snapshot archive
|
|
12
12
|
|
|
13
13
|
`run()` is the programmatic entry. It records `traces/{run_id}.jsonl`, writes `responses/{run_id}.json`,
|
|
14
|
-
and
|
|
14
|
+
and (host-side, AFTER the run) emits a SIEM signal when the assembled verdict warrants it. A host-side
|
|
15
15
|
BASELINE indicator scan runs at ingest and rides in the run_start meta, so the deterministic evidence is
|
|
16
16
|
in the trace before the planner takes a turn (MF3). `scan`/`render`/`export` work offline;
|
|
17
17
|
`pr`/`issue`/`classify` need model creds (DS_* env) + a Deno sandbox (`classify` only *ingests* offline).
|
|
18
|
-
`scan` exposes that deterministic baseline on its own
|
|
18
|
+
`scan` exposes that deterministic baseline on its own: the half we can run in our own CI on an untrusted
|
|
19
19
|
fork PR, where no creds exist and none may be handed over (see `_cmd_scan`).
|
|
20
20
|
"""
|
|
21
21
|
|
|
@@ -56,7 +56,7 @@ async def detect_from_event(
|
|
|
56
56
|
setup(config)
|
|
57
57
|
task = ClassifyChange(config=config, extra_tools=extra_tools, event=event)
|
|
58
58
|
event_str = normalize_event(event)
|
|
59
|
-
# Host-side BASELINE scan
|
|
59
|
+
# Host-side BASELINE scan: deterministic evidence in the trace before the planner runs (MF3).
|
|
60
60
|
baseline = _baseline_indicators(event)
|
|
61
61
|
|
|
62
62
|
async def _run():
|
|
@@ -67,23 +67,23 @@ async def detect_from_event(
|
|
|
67
67
|
# The run's INITIAL STATE (the untrusted event is a REPL variable, not a chat turn) + prompt.
|
|
68
68
|
"event": event_str,
|
|
69
69
|
"instructions": task.instructions,
|
|
70
|
-
# The change under review
|
|
70
|
+
# The change under review: echoed into the response envelope + the SIEM signal.
|
|
71
71
|
"source": _source_echo(event),
|
|
72
72
|
# The host-derived facts about the author and the event (account age as of the event, profile,
|
|
73
73
|
# timestamps, fork, commit offsets), so an offline reader sees what the ingest saw.
|
|
74
74
|
**({"provenance": event["provenance"]} if event.get("provenance") else {}),
|
|
75
|
-
# The deterministic baseline
|
|
75
|
+
# The deterministic baseline: assemble unions this with every scan_indicators tool call.
|
|
76
76
|
"baseline_indicators": baseline,
|
|
77
77
|
# The emit threshold THIS run used, so an offline re-render/export re-derives the SAME signal.
|
|
78
78
|
"emit_on": list(config.emit_on),
|
|
79
|
-
# The models actually used this run (roles → concrete names)
|
|
79
|
+
# The models actually used this run (roles → concrete names): self-describing trace.
|
|
80
80
|
"planner": config.main_model,
|
|
81
81
|
"analyst": config.sub_model,
|
|
82
82
|
"classifier": config.classifier_model,
|
|
83
83
|
# The budget THIS run ran under, so an offline reader computes hit_iteration_cap correctly.
|
|
84
84
|
"max_iterations": config.max_iterations,
|
|
85
85
|
"max_llm_calls": config.max_llm_calls,
|
|
86
|
-
# The ATLAS rubric decomposition carried as LABELS (structure only
|
|
86
|
+
# The ATLAS rubric decomposition carried as LABELS (structure only; the trainer scores dᵢ). A
|
|
87
87
|
# FIXED skeleton (default_rubric): diff-sentry's task is constant, so only the per-criterion
|
|
88
88
|
# FACTS vary per run (re-sourced from the trace on read; see rubric.criteria_facts).
|
|
89
89
|
"rubric": rubric_to_meta(default_rubric()),
|
|
@@ -166,7 +166,7 @@ def run(
|
|
|
166
166
|
emit_failed: bool = True,
|
|
167
167
|
) -> RunArtifacts:
|
|
168
168
|
"""THE programmatic entry: classify one change, write the response, and (host-side, after the run)
|
|
169
|
-
emit a SIEM signal when warranted. Never raises on a run failure
|
|
169
|
+
emit a SIEM signal when warranted. Never raises on a run failure: a failed run still writes an
|
|
170
170
|
informative response and returns result-less RunArtifacts."""
|
|
171
171
|
from rlm_harness.trace import load_events
|
|
172
172
|
|
|
@@ -181,7 +181,7 @@ def run(
|
|
|
181
181
|
try:
|
|
182
182
|
verdict = asyncio.run(detect_from_event(event, config, trace_path=trace_path, run_id=run_id,
|
|
183
183
|
on_event=on_event, extra_tools=extra_tools))
|
|
184
|
-
except Exception as exc: # noqa: BLE001
|
|
184
|
+
except Exception as exc: # noqa: BLE001 (a failed run is still self-contained + navigable)
|
|
185
185
|
events = load_events(trace_path, run_id) if os.path.exists(trace_path) else []
|
|
186
186
|
resp = build_failed_response(run_id, events, f"{type(exc).__name__}: {exc}")
|
|
187
187
|
response_path = _write((os.path.join(outdir, "responses"), f"{run_id}.json"),
|
|
@@ -299,11 +299,11 @@ def drop_deleted_lines(text: str) -> str:
|
|
|
299
299
|
"""Strip a unified diff's `-` deletion lines, keeping additions and context.
|
|
300
300
|
|
|
301
301
|
A diff carries the code being REMOVED as well as the code being added, and the detectors read plain
|
|
302
|
-
text
|
|
302
|
+
text, so scanning a raw diff flags a change for a payload it is DELETING. That turns every
|
|
303
303
|
remediation commit red, which is the worst possible failure mode for a security gate: it penalises
|
|
304
304
|
exactly the change you wanted. `---` (the file header) is kept; only true `-` deletion lines go.
|
|
305
305
|
|
|
306
|
-
Non-diff input is returned untouched
|
|
306
|
+
Non-diff input is returned untouched: the check is per line, so a plain source file has no `-`-
|
|
307
307
|
prefixed lines to lose. `scan --include-deletions` opts back in when you want to audit what a change
|
|
308
308
|
removed (a payload can also be hidden by deleting a check)."""
|
|
309
309
|
if not text:
|
|
@@ -314,10 +314,10 @@ def drop_deleted_lines(text: str) -> str:
|
|
|
314
314
|
|
|
315
315
|
def _cmd_scan(args) -> int:
|
|
316
316
|
"""The deterministic HALF of the pipeline, standalone: run the indicator suite over text and exit
|
|
317
|
-
non-zero on a hit at/above the floor. No LLM, no network, no creds, no Deno
|
|
317
|
+
non-zero on a hit at/above the floor. No LLM, no network, no creds, no Deno, just `indicators`.
|
|
318
318
|
|
|
319
319
|
This is what makes the detector safe to dogfood in our OWN CI. The full `pr`/`classify` path needs
|
|
320
|
-
`DS_*` creds, and the only way to hand creds to a fork PR's run is `pull_request_target
|
|
320
|
+
`DS_*` creds, and the only way to hand creds to a fork PR's run is `pull_request_target`, the exact
|
|
321
321
|
misconfiguration that opened the AsyncAPI "Miasma" supply-chain compromise. `scan` needs nothing, so
|
|
322
322
|
it runs under the same read-only `contents: read` token an untrusted fork PR already gets."""
|
|
323
323
|
from .indicators import scan_diff
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Configuration for diff-sentry
|
|
1
|
+
"""Configuration for diff-sentry: model ROLES, never hardcoded model names.
|
|
2
2
|
|
|
3
3
|
Three ROLES (the rlm-harness-consumer convention): the RLM PLANNER (root LM) drives the triage loop and
|
|
4
4
|
holds the diff in the REPL; the ANALYST (sub LM, reached via `llm_query`) is an expensive brain for a
|
|
@@ -103,7 +103,7 @@ def _classifier_lm_kwargs() -> dict | None:
|
|
|
103
103
|
|
|
104
104
|
@dataclass(frozen=True)
|
|
105
105
|
class DetectConfig:
|
|
106
|
-
# Planner (main) + analyst (sub), sharing one OpenAI-compatible proxy. No model name is assumed
|
|
106
|
+
# Planner (main) + analyst (sub), sharing one OpenAI-compatible proxy. No model name is assumed:
|
|
107
107
|
# `from_env` requires them. Empty defaults are only for direct construction in tests.
|
|
108
108
|
main_model: str = "" # planner: a cheap, injection-resistant orchestrator driving the REPL loop
|
|
109
109
|
sub_model: str = "" # analyst: an expensive brain for a subtle case (reached via llm_query)
|
|
@@ -121,11 +121,11 @@ class DetectConfig:
|
|
|
121
121
|
classifier_transient_retries: int = 1
|
|
122
122
|
# Extra request parameters of the classifier's call, over its `temperature=0.1` (`_classifier_lm_kwargs`).
|
|
123
123
|
classifier_lm_kwargs: dict | None = None
|
|
124
|
-
# After this many CONSECUTIVE
|
|
124
|
+
# After this many CONSECUTIVE unusable replies or repeating error statuses the tool short-circuits (make_model_tool).
|
|
125
125
|
classifier_circuit_break: int = 4
|
|
126
126
|
|
|
127
127
|
# ── RLM runtime knobs ────────────────────────────────────────────────────────────────────────
|
|
128
|
-
interpreter: str = "pyodide" # read-not-execute: the classifier NEVER runs the change (see
|
|
128
|
+
interpreter: str = "pyodide" # read-not-execute: the classifier NEVER runs the change (see docs/how-it-works.md)
|
|
129
129
|
observe: bool = False
|
|
130
130
|
adapter: str = "json"
|
|
131
131
|
# Generous, NOT None: a reasoning planner truncated before its answer returns empty content.
|
|
@@ -135,14 +135,14 @@ class DetectConfig:
|
|
|
135
135
|
# reasoning planner from thinking through the whole `planner_max_tokens` and answering nothing.
|
|
136
136
|
planner_lm_kwargs: dict | None = None
|
|
137
137
|
analyst_lm_kwargs: dict | None = None
|
|
138
|
-
# HARD ceiling on the single RLM episode. No outer multi-run loop (max_retries=1)
|
|
138
|
+
# HARD ceiling on the single RLM episode. No outer multi-run loop (max_retries=1): one change =
|
|
139
139
|
# one trajectory, so the trace stays valid training data. A failed run is INFRA, not a schema bug:
|
|
140
140
|
# a non-retryable LM error escapes as the raw dspy.LMError, the rest as RLMTaskError.
|
|
141
141
|
max_iterations: int = 25
|
|
142
142
|
max_llm_calls: int = 8 # caps ONLY analyst (llm_query) escalations
|
|
143
143
|
max_output_chars: int = 10_000 # head+tail char cap dspy.RLM applies to each REPL output
|
|
144
144
|
|
|
145
|
-
# ── Enrichment fetch (opt-in, GitHub-allowlisted
|
|
145
|
+
# ── Enrichment fetch (opt-in, GitHub-allowlisted, MF2) ──────────────────────────────────────
|
|
146
146
|
enable_fetch: bool = False # OFF by default: fetch on attacker input is an exfil surface
|
|
147
147
|
fetch_timeout: float = 20.0
|
|
148
148
|
fetch_max_bytes: int = 400_000
|
|
@@ -157,7 +157,7 @@ class DetectConfig:
|
|
|
157
157
|
siem_webhook_url: str = ""
|
|
158
158
|
siem_token: str = ""
|
|
159
159
|
# Verdicts (or a deterministic high/critical indicator) that emit a signal. A benign self-report
|
|
160
|
-
# cannot suppress a hard indicator
|
|
160
|
+
# cannot suppress a hard indicator; see assemble.assemble_verdict.
|
|
161
161
|
emit_on: tuple[str, ...] = ("suspicious", "malicious")
|
|
162
162
|
|
|
163
163
|
@property
|
|
@@ -170,7 +170,7 @@ class DetectConfig:
|
|
|
170
170
|
|
|
171
171
|
@classmethod
|
|
172
172
|
def screen_from_env(cls) -> DetectConfig:
|
|
173
|
-
"""A config with NO model roles
|
|
173
|
+
"""A config with NO model roles, only the SIEM emitter and `emit_on`. For the deterministic screen
|
|
174
174
|
(`watch --screen-only`), which never runs the model and so must not require `DS_ROOT_LM`."""
|
|
175
175
|
return cls(
|
|
176
176
|
siem_webhook_url=os.getenv("DS_SIEM_WEBHOOK_URL", ""),
|