diff-sentry 0.6.0__tar.gz → 0.7.0__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.
Files changed (160) hide show
  1. diff_sentry-0.7.0/.claude/rules/detect-setup.md +19 -0
  2. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/.claude/rules/handoff.md +6 -15
  3. diff_sentry-0.7.0/.claude/rules/indicators.md +148 -0
  4. diff_sentry-0.7.0/.claude/rules/monitoring.md +44 -0
  5. diff_sentry-0.7.0/.claude/rules/rubric-and-eval.md +26 -0
  6. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/.claude/skills/release/SKILL.md +2 -2
  7. diff_sentry-0.7.0/.env.example +81 -0
  8. diff_sentry-0.7.0/AGENTS.md +58 -0
  9. diff_sentry-0.7.0/CHANGELOG.md +164 -0
  10. diff_sentry-0.7.0/PKG-INFO +214 -0
  11. diff_sentry-0.7.0/README.md +193 -0
  12. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/action.yml +1 -1
  13. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/__init__.py +1 -1
  14. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/actors.py +1 -1
  15. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/assemble.py +4 -1
  16. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/cli.py +4 -2
  17. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/config.py +79 -0
  18. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/deep_classify.py +33 -9
  19. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/detect.py +62 -10
  20. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/emit.py +6 -2
  21. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/indicators.py +812 -39
  22. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/response.py +9 -8
  23. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/rl_export.py +4 -1
  24. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/schema.py +62 -1
  25. diff_sentry-0.7.0/diff_sentry/skills/ci-and-workflows.md +53 -0
  26. diff_sentry-0.7.0/diff_sentry/skills/concealment-and-provenance.md +45 -0
  27. diff_sentry-0.7.0/diff_sentry/skills/developer-autorun.md +44 -0
  28. diff_sentry-0.7.0/diff_sentry/skills/llm-directed-text.md +47 -0
  29. diff_sentry-0.7.0/diff_sentry/skills/package-and-build-hooks.md +46 -0
  30. diff_sentry-0.7.0/diff_sentry/skills/runtime-droppers-and-exfil.md +43 -0
  31. diff_sentry-0.7.0/diff_sentry/skills/triage-a-change.md +59 -0
  32. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/watch.py +264 -65
  33. diff_sentry-0.7.0/eval/README.md +81 -0
  34. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/pyproject.toml +2 -2
  35. diff_sentry-0.7.0/studio/DESIGN.md +126 -0
  36. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/diff_sentry_studio/app.py +2 -1
  37. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/diff_sentry_studio/iterations.py +1 -1
  38. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/diff_sentry_studio/mapper.py +1 -1
  39. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/static/app.js +9 -3
  40. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/tests/test_app.py +17 -0
  41. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/corpus/change_corpus.json +127 -1
  42. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_adversarial_input.py +2 -2
  43. diff_sentry-0.7.0/tests/test_config.py +190 -0
  44. diff_sentry-0.7.0/tests/test_deep_classify.py +153 -0
  45. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_detect.py +57 -1
  46. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_detection_quality.py +6 -5
  47. diff_sentry-0.7.0/tests/test_file_scope.py +191 -0
  48. diff_sentry-0.7.0/tests/test_file_structure.py +425 -0
  49. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_forward_offline.py +62 -3
  50. diff_sentry-0.7.0/tests/test_grounding.py +305 -0
  51. diff_sentry-0.7.0/tests/test_inline_exec_and_bidi.py +435 -0
  52. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_metadata_rules.py +33 -30
  53. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_schema.py +16 -1
  54. diff_sentry-0.7.0/tests/test_watch_pool.py +346 -0
  55. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/uv.lock +16 -13
  56. diff_sentry-0.6.0/.claude/rules/detect-setup.md +0 -23
  57. diff_sentry-0.6.0/.claude/rules/indicators.md +0 -281
  58. diff_sentry-0.6.0/.claude/rules/monitoring.md +0 -106
  59. diff_sentry-0.6.0/.claude/rules/rubric-and-eval.md +0 -42
  60. diff_sentry-0.6.0/.env.example +0 -67
  61. diff_sentry-0.6.0/AGENTS.md +0 -151
  62. diff_sentry-0.6.0/CHANGELOG.md +0 -233
  63. diff_sentry-0.6.0/PKG-INFO +0 -335
  64. diff_sentry-0.6.0/README.md +0 -314
  65. diff_sentry-0.6.0/diff_sentry/skills/ci-injection-patterns.md +0 -48
  66. diff_sentry-0.6.0/diff_sentry/skills/prompt-injection-catalog.md +0 -33
  67. diff_sentry-0.6.0/diff_sentry/skills/triage-a-change.md +0 -44
  68. diff_sentry-0.6.0/eval/README.md +0 -113
  69. diff_sentry-0.6.0/studio/DESIGN.md +0 -227
  70. diff_sentry-0.6.0/tests/test_config.py +0 -79
  71. diff_sentry-0.6.0/tests/test_deep_classify.py +0 -76
  72. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/.github/workflows/ci.yml +0 -0
  73. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/.github/workflows/release.yml +0 -0
  74. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/.gitignore +0 -0
  75. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/.python-version +0 -0
  76. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/LICENSE +0 -0
  77. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/VENDOR.md +0 -0
  78. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/__main__.py +0 -0
  79. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/dbutil.py +0 -0
  80. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/fetch_tool.py +0 -0
  81. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/hosts.py +0 -0
  82. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/ingest.py +0 -0
  83. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/linewide.py +0 -0
  84. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/normalize.py +0 -0
  85. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/rubric.py +0 -0
  86. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/shelllex.py +0 -0
  87. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/diff_sentry/snapshot.py +0 -0
  88. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/diff_sentry_eval/__init__.py +0 -0
  89. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/diff_sentry_eval/__main__.py +0 -0
  90. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/diff_sentry_eval/cli.py +0 -0
  91. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/diff_sentry_eval/judge.py +0 -0
  92. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/diff_sentry_eval/schema.py +0 -0
  93. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/diff_sentry_eval/score.py +0 -0
  94. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/diff_sentry_eval/taskset.py +0 -0
  95. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/pyproject.toml +0 -0
  96. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/taskset.example.json +0 -0
  97. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/tests/conftest.py +0 -0
  98. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/tests/test_boundary.py +0 -0
  99. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/tests/test_cli.py +0 -0
  100. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/tests/test_judge.py +0 -0
  101. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/tests/test_schema.py +0 -0
  102. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/tests/test_score.py +0 -0
  103. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/eval/tests/test_taskset.py +0 -0
  104. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/README.md +0 -0
  105. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/diff_sentry_studio/__init__.py +0 -0
  106. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/diff_sentry_studio/live.py +0 -0
  107. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/pyproject.toml +0 -0
  108. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/static/index.html +0 -0
  109. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/static/replay-core.js +0 -0
  110. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/static/run-core.js +0 -0
  111. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/static/style.css +0 -0
  112. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/static/trajectory.js +0 -0
  113. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
  114. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
  115. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/tests/replay-core.test.js +0 -0
  116. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/tests/run-core.test.js +0 -0
  117. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/tests/static-contract.test.js +0 -0
  118. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/tests/test_iterations.py +0 -0
  119. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/tests/test_live.py +0 -0
  120. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/tests/test_mapper.py +0 -0
  121. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/studio/tests/trajectory.test.js +0 -0
  122. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/__init__.py +0 -0
  123. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/conftest.py +0 -0
  124. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/corpus/hackerbot_claw_campaign.json +0 -0
  125. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
  126. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/fixtures/known-malicious.diff +0 -0
  127. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/oracle/__init__.py +0 -0
  128. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/oracle/grammar.py +0 -0
  129. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/oracle/harness.py +0 -0
  130. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/oracle/lexrunner.py +0 -0
  131. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/oracle/programs.py +0 -0
  132. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/oracle/runner.py +0 -0
  133. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_actors.py +0 -0
  134. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_assemble.py +0 -0
  135. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_campaign_replay.py +0 -0
  136. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_cli.py +0 -0
  137. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_cli_watch.py +0 -0
  138. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_dogfood_gate.py +0 -0
  139. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_emit.py +0 -0
  140. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_enclosure.py +0 -0
  141. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_export.py +0 -0
  142. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_fetch_tool.py +0 -0
  143. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_gate_edges.py +0 -0
  144. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_hosts.py +0 -0
  145. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_incident_hackerbot_claw.py +0 -0
  146. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_indicators.py +0 -0
  147. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_ingest.py +0 -0
  148. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_ingest_metadata.py +0 -0
  149. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_injection_offline.py +0 -0
  150. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_linewide.py +0 -0
  151. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_normalize.py +0 -0
  152. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_oracle.py +0 -0
  153. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_oracle_harness.py +0 -0
  154. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_provenance.py +0 -0
  155. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_response.py +0 -0
  156. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_rubric.py +0 -0
  157. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_shelllex.py +0 -0
  158. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_snapshot.py +0 -0
  159. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_subscription.py +0 -0
  160. {diff_sentry-0.6.0 → diff_sentry-0.7.0}/tests/test_watch.py +0 -0
@@ -0,0 +1,19 @@
1
+ ---
2
+ paths:
3
+ - "diff_sentry/detect.py"
4
+ - "diff_sentry/config.py"
5
+ - "diff_sentry/cli.py"
6
+ - "diff_sentry/deep_classify.py"
7
+ - "diff_sentry/__init__.py"
8
+ - ".env.example"
9
+ ---
10
+
11
+ # Model roles, auth, and run failures
12
+
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` (the "LM Response" quoted is its promoted reasoning, or `{}`), which reads like a format failure; the raw call's `finish_reason` tells them apart. 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
+ - **`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: that call would fail every time as an endpoint error, which the circuit breaker never counts.
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
+ - **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
+ - **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.
@@ -1,7 +1,6 @@
1
1
  # Context preservation (read before auto-compacting)
2
2
 
3
- Durable knowledge belongs in tracked files; a handoff summary carries only the in-flight session state
4
- they do not hold yet.
3
+ Durable knowledge belongs in tracked files; a handoff summary carries only the in-flight session state they do not hold yet.
5
4
 
6
5
  - **Stable invariants** → `AGENTS.md` **Hard invariants**, or the topic rule under `.claude/rules/`.
7
6
  - **Shipped changes** → the commit message, and a `CHANGELOG.md` entry at release.
@@ -9,22 +8,14 @@ they do not hold yet.
9
8
 
10
9
  A handoff summary covers, in order:
11
10
 
12
- 1. **Decisions agreed this session** that are not yet in those files, each with its reason. Promote
13
- durable ones into `AGENTS.md` or a topic rule before they fade.
14
- 2. **Files / symbols changed**, as `path:symbol — final shape` one-liners (e.g.
15
- `assemble.py:assemble_verdict — signal = verdict∈emit_on OR severity≥floor`). No diffs, no
16
- intermediate revisions.
17
- 3. **Current status**: what passes (`uv run pytest`, the eval suite, `uvx ruff check .`) with counts,
18
- what is broken, the last command and its result. One paragraph.
11
+ 1. **Decisions agreed this session** that are not yet in those files, each with its reason. Promote durable ones into `AGENTS.md` or a topic rule before they fade.
12
+ 2. **Files / symbols changed**, as `path:symbol — final shape` one-liners (e.g. `assemble.py:assemble_verdict — signal = verdict∈emit_on OR severity≥floor`). No diffs, no intermediate revisions.
13
+ 3. **Current status**: what passes (`uv run pytest`, the eval suite, `uvx ruff check .`) with counts, what is broken, the last command and its result. One paragraph.
19
14
  4. **Open items** not yet tracked, each marked `proposed`, `accepted-not-done`, or `rejected`.
20
- 5. **Seams touched**: `classify_backend` (`"self"` or a dedicated backend in progress), the host-side
21
- pre-filter / live GitHub+SIEM wiring, and the rlm-harness pin. A resumed session must not re-open a
22
- seam that moved.
15
+ 5. **Seams touched**: `classify_backend` (`"self"` or a dedicated backend in progress), the host-side pre-filter / live GitHub+SIEM wiring, and the rlm-harness pin. A resumed session must not re-open a seam that moved.
23
16
  6. **The user's in-flight intent and acceptance criteria.**
24
17
 
25
- Do not preserve anything already in `AGENTS.md`, `README.md`, `.env.example`, or `pyproject.toml`; tool
26
- output, file listings, or file contents readable from disk; or exploration narration that led to no
27
- decision.
18
+ Do not preserve anything already in `AGENTS.md`, `README.md`, `.env.example`, or `pyproject.toml`; tool output, file listings, or file contents readable from disk; or exploration narration that led to no decision.
28
19
 
29
20
  Format (keep it under ~40 lines):
30
21
 
@@ -0,0 +1,148 @@
1
+ ---
2
+ paths:
3
+ - "diff_sentry/indicators.py"
4
+ - "diff_sentry/normalize.py"
5
+ - "tests/corpus/**"
6
+ - "tests/test_indicators.py"
7
+ - "diff_sentry/hosts.py"
8
+ - "tests/oracle/**"
9
+ - "tests/test_oracle.py"
10
+ - "tests/test_oracle_harness.py"
11
+ - "diff_sentry/shelllex.py"
12
+ - "diff_sentry/linewide.py"
13
+ - "tests/test_shelllex.py"
14
+ - "tests/test_enclosure.py"
15
+ - "tests/test_linewide.py"
16
+ - "tests/test_gate_edges.py"
17
+ - "tests/test_metadata_rules.py"
18
+ - "tests/test_adversarial_input.py"
19
+ - "tests/test_detection_quality.py"
20
+ - "tests/test_incident_*.py"
21
+ ---
22
+
23
+ # Indicator rules
24
+
25
+ ## Rules that hold for every indicator
26
+
27
+ - **Deterministic, pure Python, no subprocess.** A subprocess spawned inside the live dspy.RLM/asyncio process hangs; a heavier scanner belongs host-side, after the run. `mint_id` is a sha1 over rule and evidence (no time, no randomness), so a baseline hit and an in-loop re-scan of the same content dedupe to one union member. Evidence is a bounded snippet (`_MAX_EVIDENCE`), never the whole diff.
28
+ - **Severities are tuned, not paranoid.** A plain workflow-file edit is `medium`, below the signal floor on purpose: a benign workflow PR must not force a SIEM signal, and a real payload inside the workflow fires the high/critical shell and obfuscation rules itself. A CODEOWNERS reassignment is `high`. A bare `pull_request_target` (the ordinary label-bot shape) is `medium`; the same trigger checking out the PR head is `pwn-request` / `critical`. `dev-tunnel-endpoint`, `content-addressed-host` (IPFS) and `dynamic-code-eval` are sub-floor corroborators; pairs with no innocent reading (`child_process` + `detached`, network-fetch + file-write) are `high`. Requiring both halves is how a common primitive becomes evidence without becoming noise.
29
+ - **Every rule ships with its negative case**, and `tests/corpus/` pins the suite's hit/miss behavior.
30
+ - **`pwn-request` has three checkout forms, each requiring the privileged trigger:** the head expression on a `ref:` (directly or one binding hop away), a hand-rolled `git fetch` / `gh pr checkout`, and `allow-unsafe-pr-checkout: true` (the actions/checkout v5.1+ opt-in; the line states the intent, so no dataflow is needed). Each form has its own title. A privileged trigger is recognised in the common YAML spellings (map key, `on: name`, flow list or map, block-list item, quotes, anchor, trailing comment). `issue_comment` counts beside `pull_request_target` and `workflow_run` (it runs with the base repository's secrets), but that trigger alone is not a finding; `pull_request_review` and `pull_request_review_comment` are not privileged.
31
+ - **A paired rule only pairs within one file.** `scan_diff` splits a unified diff on its file headers and `scan_content` does the same for an event. `normalize.content_segments` is the single definition both `raw_content` and the baseline build on. Per-file scoping stops two halves from different files composing into a `critical` no file contains, and keeps a hit id byte-stable between a whole-diff and a single-file scan, which is what lets the baseline and an in-loop scan dedupe. Non-diff input falls through to a whole-text scan.
32
+ - **The in-loop tool registers as exactly `scan_indicators`.** dspy registers a tool under its `__name__` and the prompt calls `scan_indicators(region)`; the inner callable is renamed on purpose (`make_indicator_tool`).
33
+ - **Hits are capped at 25 per (rule, place, title)** (`_MAX_HITS_PER_RULE`), most severe first, so decoys cannot evict the critical that follows them. The title is part of the key because `curl-pipe-shell` has three forms. The SIEM payload lists the 100 most severe and says how many there were (`indicator_count`).
34
+ - **A detector that raises is a finding, not a silence.** `scan_indicators` runs each detector in `_DETECTORS` under a guard; one that raises records a `high` `scanner-error` hit and the rest still run. `tests/test_metadata_rules.py` feeds thousands of random hostile strings through the scanners and requires that none raises. The prose softening and the rendered view run under the same kind of guard (`_or_closed`): a crash keeps the installer as strict as the detector found it and adds a `scanner-error` hit.
35
+ - **Every regex is linear on hostile input.** The watcher runs these rules on any user's text (up to 65,536 characters in a body) on one poller thread, and a regex that backtracks does not return. Habits: `[ \t]*` after a line anchor, never `\s*`; no two adjacent quantifiers that can take the same character; alternatives disjoint on their first character; every unbounded `[^\n]*` in front of another pattern gets a `{0,N}` window; host patterns anchored and label-bounded. `tests/test_adversarial_input.py` times named vectors and, in a subprocess, runs 65,536-character inputs built from the literals of every module-level regex in the package. A regex written inside a function is not picked up: compile it at module level.
36
+ - **The change text is scanned once per change in the poller.** `cli._baseline_indicators(event, content_hits=...)` and `cli.screen(..., baseline=...)` take the result of the one `scan_change`.
37
+ - **Command words are bounded by ASCII alphanumerics, never `\b` or `\w`**, which know Unicode: `__curl ... | sh__` (Markdown emphasis) and `執行curl ...` (CJK prose has no spaces) must still be found.
38
+ - **`invisible-unicode`** covers bidi overrides, embeddings and isolates; the word joiner and invisible math operators; the Hangul fillers; a ZWSP anywhere but right after `@` (Dependabot renders `@​user`); a BOM anywhere but the start of a line; Unicode tag characters (except the three subdivision flags); the variation-selector supplement; and three or more plain variation selectors in a row. It is `medium` in a body or a comment (the baseline never softens it in a title or a file) when all it found are embeddings and isolates, at least one opener, each opened after right-to-left text (the strong character nearest before it in its paragraph is Arabic or Hebrew; a mark such as U+200F is not one), and their closers: Gmail quoting an Arabic reply. An override (U+202D, U+202E) keeps it `high`. An LLM reads logical order, so these marks hide nothing from a model or the other rules; they only reorder words for a human reader. The planner's scan reads every region it copies from the change as a body, so a region of a title or a file is `medium` there and the baseline's `high` for that segment stays in the union; a value it decodes keeps `high`.
39
+ - **`inline-code-exec`** (`node -e`, or `-e` handed to `process.execPath`) is bounded by ASCII and also found after a string's escape of a line break or a tab (`\n`, `\x0a`, `\u000a`, `\t` and the like); `node\t-e` in a string is one too. Between `node` and `-e` a line break may carry a `+` (a YAML folded scalar puts them on lines of their own). `process.execPath` and `'-e'` may be any 160 characters apart (`spawn(process.execPath, [<newline>'-e', code])`, as prettier wraps it); another program's `'-e'` that close (`spawnSync('perl', ['-e', ...])`), also across a hunk header, is an accepted over-alert. Known gaps: a backslash-continued `node \`, `node --eval`, `node -p`, and `node --input-type=module -e`.
40
+ - **A file's patch is read as its new file** (`_exec_view`) when its caller knows it is one: a file of the change whose name holds no line break, a file of a `diff --git` diff (not the text in front of the first one). The removed lines and `\ No newline` notes that rewriting a fold's `-e` line leaves between `node` and `-e` hide nothing, a removed `'-e'` is no longer run, and a hunk header (only one git writes, `@@ -a,b +c,d @@`) puts no `node` next to an `-e`; its section heading, an earlier line of the file, is read as a line of its own (when it is the line right above the hunk, a fold across it is not read: the `node -e` it folds is unchanged code). Any other line makes the text be read as it is, and so does a lone CR or a NUL (the file's language breaks a line at a CR and git does not; a NUL is deleted before a line is judged). Every other text is read as it is, whatever it spells: a comment, a bot's comment, a decoded value and a planner's region can all hold a hunk header line and bullets or `\` lines that a patch reader would drop.
41
+ - **It is `medium` in a body or a comment whose writer is an OWNER, MEMBER or COLLABORATOR** (an issue's reproduction steps; a comment is judged by its own writer). A title, a branch and every file keep it `high`, and so does an outsider's text: a Clinejection-shaped issue asks a bot to run a `node -e` that reads `process.env` or `~/.npmrc` and nothing else carries the floor there. Source code keeps it too: a hunk rarely shows the import of what runs the string (`exec(`, a promisified alias, a hook or rc file the code writes), so no sink test holds.
42
+ - **The planner's scan** reads every region as it is, beside the patch reading of any `diff --git` diff it holds, so it never reads less than the text holds: it cannot tell a file's diff from one a bot's comment pasted, nor a decoded value's `-function(){…}()` lines from removed ones. A scan of a file's hunk that removes a `node -e` line, or a `'-e'` near `process.execPath`, is therefore `high` there (the baseline, which reads the file as its new file, is not): an accepted over-alert. A region copied from the change, and not passed as a decoded value, is `medium` when every `node -e` in it (at most 50) has the same window, 120 characters on each side with whitespace collapsed, as one the baseline softened (the author's body and own comments; a maintainer's comment on someone else's change is not scanned, and a scan of it stays `high`), and it holds no `diff --git` diff (`_written_by_maintainer`). A match's lines alone do not do: a file can share them with the maintainer's text while running something else (`node` / `-e` / code over three lines of a YAML folded scalar). A decoded value keeps it `high`, and so does a region that cuts a window short (a line or a code block of a longer body): an accepted over-alert. What it leaves to a trusted author is text the baseline never reads as `node -e` (a bot's comment, the actor history, a spelling in the known gaps) that copies one of their own windows and runs a payload past it.
43
+ - **`branch-name-injection` is `high` unless the author is an OWNER, MEMBER or COLLABORATOR** (then `medium`): a maintainer can name a branch `fix/handle_${HOME}_expansion`.
44
+
45
+ ## `curl-pipe-shell`
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.
48
+
49
+ ### What it matches
50
+
51
+ - **The pipe form.** The shell is `sh bash zsh dash ksh ash fish csh tcsh` by name or path (quotes or backslashes threaded through the letters are fine), `$SHELL`, or one of them behind `env`, `busybox`, `command`, `exec`, `nohup`, `nice`, `timeout`, `stdbuf`, `sudo`, `doas`, `pkexec` or `su -c`. An interpreter that reads its program from the pipe (`python3 -`, `perl`, `ruby`, `node`, `php`) counts too, but not `python -m`, a script file or `-c` / `-e`. The first pipe into a shell is the sink; up to three stages in between; the download word may itself be quoted or split by a backslash-newline; up to 300 characters of arguments sit between it and the pipe, after the padding that would push a real command out of that window has been folded. The window reads quoted strings (which may run over a line), `$'...'` as an ANSI-C string, and a backslash outside a string as bash does (`\'`, `\|` and `\$` are characters of a word, `curl -H 'a<newline>b' URL \' | sh`). A second window reads `$` as a plain character (after `\$` or `$$`, and in dash, `$'` starts no ANSI-C string), and a third ignores quotes altogether (it finds a Markdown table cell's `\|`). A match of any of the three counts.
52
+ - **The command substitution form:** `bash -c "$(curl ...)"`, `bash <(curl ...)`, `source <(curl ...)`, `eval "$(curl ...)"`, `python3 <(curl ...)`, here-strings, with the usual prefixes.
53
+ - **The PowerShell form:** `iwr ... | iex` (`curl` and `wget` are aliases of `iwr` there), `iex (irm ...)`, `iex ((New-Object Net.WebClient).DownloadString(...))`, `[scriptblock]::Create(...)`.
54
+ - A backslash-continued command and a pipe at the end of a line are joined before matching (`_join_lines`), in the scan and in the softening alike. A line that looks like a table row but is not one keeps its lines, and its pipeline is appended at the end of the text; the appended lines are matched as a text of their own (`_matches`). Prose and HTML files are also scanned as rendered (`_rendered`, below), and the hits only that view has are added.
55
+
56
+ ### When prose makes it `medium`
57
+
58
+ Every download of the form in the segment must meet all of these; one failure keeps the whole form `critical`. The form is part of the hit id, and two hits that share an id keep the more severe one (`keep_worse`).
59
+
60
+ 1. **Known installers only** (`hosts.KNOWN_INSTALLERS`: exact hosts, and exact path patterns for shared hosts such as `raw.githubusercontent.com/nvm-sh/nvm/...`). `hosts.installer_only` is strict: every flag is on an allowlist (`-sSfL`, `-o-`, `--proto '=https'`, `--retry N`; `--proxy`, `--config`, `--next` and the like fail), every argument is a clean URL, and a stand-in (`...`, `<url>`) names no download. `powershell_installer_only` applies the same test to PowerShell. Never add a whole platform (github.com, raw.githubusercontent.com) to the list.
61
+ 2. **A plain shell side** (pipe form). A bare shell name or one under `/bin`, `/usr/bin`, `/usr/local/bin`, plain flags, no `-c`, no script operand (`sh ./evil.sh`), no `=` or URL; only decode or copy filters between the download and the shell (`base64 gunzip gzip zcat xz tee cat`); an interpreter only for a known installer and only with the switches it documents.
62
+ 3. **It stands on its own logical line.** Nothing on the line feeds it (`|`, `=`, `&`, `;`, `$`, `xargs`, `-exec`; only `NONINTERACTIVE=`, `CI=` and `DEBIAN_FRONTEND=` may precede it), and no program runs it elsewhere or in its place (`_wrapped` reads every word in front of the command, after markup, labels, list markers and redirects are dropped). The command starts within `_MAX_PROSE_LEAD` characters of its line or inside an inline code span, and the rest of the line is prose.
63
+ 4. **Nothing earlier in the segment changes what runs** (`_HIJACK_RE`): a curl config, an alias or function named `curl` or a shell, an assignment to `PATH`, `HOME`, `LD_*`, `BASH_ENV`, `SSL_CERT_*`, a URL assigned to any variable, a proxy variable, a write to `/etc/hosts`, PowerShell's `Set-Alias`. This is a brake on false positives, not a proof: what defeats it is still recorded at `medium` and handed to the planner as evidence.
64
+ 5. **The installer is not enclosed** by anything a wrapper opened above it, and the text has no shape the softening does not read (both below).
65
+
66
+ ### Is the installer enclosed?
67
+
68
+ `ssh evil.example 'NEWLINE curl ... | sh NEWLINE '` runs the installer inside ssh's argument, so a documented installer stays `critical` when something above it opens a wrapper's quote, here-document or substitution. `enclosed` in `_soften_prose` asks two readings of the folded join; **either one** keeps the installer `critical`.
69
+
70
+ - **The line-wide floor (`linewide.py`).** A line opens something when its last 300 characters (minus quote, list and prompt markers) hold an odd number of apostrophes (one between two letters does not count), an odd number of double quotes or a `<<`, and a word in it could run what follows (`linewide.wrapped`: a wrapper, an interpreter, a path, a name glued to the command). The 60 lines after an opening line are enclosed. The floor also asks `wrapped` of the text in front of the command on its own line. It counts the quotes the text spells, so it sees the ones a YAML scalar, a string literal, `printf`, a variable or Markdown rendering turns into the quote a shell is handed, which the lexer cannot. It knows no fence, removed line, closed string or syntax error. A text with a NUL is read with and without it.
71
+ - **Guarantee:** what the line-wide rule rates `critical` through its opening-line rule or lead check is never softened. Exceptions: the removed lines, hunk headers (not the section heading after the second `@@`, which is a line of the new file) and `\ No newline` notes of a patch (the rendered view is of the new file, so a line break it makes carries the marker of its line), and a NUL anywhere in the text (it is read without the NUL, as the shells read it).
72
+ - **Do not narrow the floor or relax the lexer's answer on top of it.** Test the lexer's relaxations through quote-free openers (`ssh evil.example $(`).
73
+ - **The lexer (`shelllex.py`)** is a one-pass quoting lexer with a stack of open constructs (quotes, `$'...'`, backticks, `$(...)`, `<(...)`, `(...)`, `$((...))`, `${...}`) and a queue of pending here-documents, held to `bash -n` and `dash -n` by the oracle. What the two shells read differently, or what needs a parser, is `unknown` (the docstring lists them) and counts as enclosed on the lines after the first one on which a command may begin like a wrapper's. A construct counts only when the command that holds it is a wrapper and does not read as prose (`_begins_with_wrapper`, `_wrapped`): `ssh evil.example '`, `docker run x sh -c "`, `ssh evil.example <<'EOF'`. A capitalised first word, an apostrophe, a URL, or a word such as `the a an is it to of use run` makes prose, and a plain-word host (`ssh host '`) reads as prose.
74
+ - The diff's `+` and a blockquote's `>` are removed, and the text is read in each stretch between code fences. A fence is a line of three or more backticks or tildes, at most three blanks in, with nothing in front of it. A fence behind a quote or list marker cuts nothing. The lines of an HTML block are text. A string opened in one block is not open in the next. CommonMark's container rules are not modelled further.
75
+ - Every reading is also tried from each wrapper word as a fresh start, because prose or a table cell can sit in front of a command (`Run:ssh`, `| docker |`). The 60 lines after it are read as a script of its own.
76
+ - `eval` runs the script its first string holds, and a string piped into a shell or an interpreter is a script too; both are read as such to a depth of three.
77
+ - A wrapper-led line whose comment holds a quote is read a second time as a zsh in a terminal reads it, where `#` is no comment.
78
+ - The enclosure reads are held to a budget of 12 units per character plus 2,000, and a command of more than 600 characters is judged from its head and tail. The budget fails closed.
79
+
80
+ ### Shapes the softening does not read, and the rendered view
81
+
82
+ - **The gate (`_unread_shape`)** is fail-closed: a text with a shape that the softening does not read is not softened at all, and the extras of its rendered view are not either. The shapes are a lone CR, a diff block in a document (a line of the new file that starts with `+` and a non-blank), a notebook array, the strings of a list that hold a download and a program, a multi-line array assignment, a YAML single-quoted scalar that goes on, and a template placeholder that spells a quote. A crash of the check is a shape.
83
+ - **The rendered view (`_rendered`)** reads a text as a reader copies it: entities decoded, comments and tags gone, a block tag a line break, links reduced to their text, emphasis markers removed, a word in backticks reduced to the word, a code span of a quote read as that quote. Its stricter hit stands (stricter wins). `_shield_html_fences` blanks fence markers inside HTML blocks first. A second view without those last two readings is read as well, because a backtick they erase still counts in front of a command (`ssh host <code> ' </code> curl ... | sh`). `.rst`, `.adoc`, `.html`, `.htm` and `.ipynb` are not Markdown, so no fence or HTML block cuts there.
84
+
85
+ ### Accepted over-alerts and known gaps
86
+
87
+ The first four bullets are over-alerts, not misses: for the examples they give, the installer stays `critical`.
88
+
89
+ - A letter-apostrophe-letter (`change's`) after a path-like first word makes the rest of that stretch of prose enclosed (`/format the change's notes`, `bin/evil doesn't work.`). Markup hides a relative path from the raw reading, but an absolute path (`/usr/bin/x`, `./x`, `~/x`) or a typographic quote hides nothing, and the rendered view reads a code span or emphasis away, so `` `src/main.py` doesn't read the config. `` before an installer is `critical`. A shell would read the apostrophe as an unterminated string.
90
+ - A line led by a wrapper that runs what follows whose comment holds a quote (`uv run pytest # run the project's tests`; not `npm test` or a plain-word host), and prose with an unmatched quote or backtick next to a bare path or a wrapper head.
91
+ - A table row with a quote, a closer or a stray `$(` or backtick opener followed by a paragraph joins that paragraph; a cell after a command written as plain text or `<code>` is a stage of its pipeline; a `+` row inside a fenced `diff` block reads as shell text.
92
+ - A tilde fence (the rendered view erases `~~~`), and any text with a gate shape.
93
+ - Known gaps: a hunk or text that starts inside a construct opened above what is visible; indented code read as prose; commands that are not known wrappers (`awk`, `parallel`, `aws ssm`, `csh`, `lua -e`); a patch whose removed lines or hunk headers sit between `curl U |` and `sh`; single-line sink forms such as `| >/dev/null sh`, `| { sh; }`, `| setsid sh`, `| xargs -0 sh -c`; a comment word followed by a backslash; a here-document on a multi-line compound command; a redirect after a here-document delimiter; more than about 300 characters of distinct words between `curl` and the pipe; a quoted-space argument (`' '`) that the padding fold turns into a lone quote (`" "` and a string that ends in a blank are folded the same way), together with a quoted argument that runs over a line; an argument that is a multi-line `$(...)`, backtick span, `${...}` or `<(...)` in front of the pipe; a quoted `|#` among the arguments (the rest of the line reads as a comment); a `$'` that is an ANSI-C string in one place and `$$` followed by a quote in another. The quote-aware window reads a backslash as a shell does and must keep doing so: reading `\'` as a plain character would catch some of these by accident, but it closes strings a shell never opens, and a window built that way backtracks exponentially on `$'a'` chains and costs hundreds of microseconds per character on `\"` chains.
94
+ - A table cell spells its pipe `\|`, and that is read as a pipe only in a table row that starts with a pipe; elsewhere `\|` is an argument of the command (`curl \| URL | sh` is `critical`). So an installer written with `\|` in a table without leading pipes, in a paragraph or in an HTML cell is `critical` too, although Markdown renders the `\|` as a pipe there. A line that starts with a pipe inside a multi-line quoted argument is read as a table row, and its `\|` as a cell's pipe.
95
+ - A lone CR is a character of a word to a shell. The shell rules read the text once more with each lone CR as a letter (`curl URL<CR>| sh` runs; `\<CR>` is an escaped CR), also for the planner tool and `diff_sentry scan`, which read a file or stdin with the CR kept.
96
+
97
+ ### Measured
98
+
99
+ - **Over-alert rate.** The uv installer inserted as its own paragraph into 2,400 real Markdown documents (7,200 insertions): about 2.5% read `critical` where the line-wide rule alone said `medium`, and none read `medium` where it said `critical`. About a quarter of those come from a gate shape (under 1% of documents hold one). Without the floor, about 3% of the insertions would have been softened that the line-wide rule kept `critical`. An installer whose own line carries a quote (the rustup one) over-alerts more, roughly one insertion in eight on a similar corpus, because a plain apostrophe in the prose above pairs with that quote.
100
+ - **Cost is linear.** The worst shape (`curl(` repeated) costs about 10 µs per character per channel (0.7 s for a 65 KB body, about 20 s for a 2 MB patch); the hostile enclosure and gate shapes cost 2 to 17 µs per character; plain prose is about 2 µs and a README 8 to 14. Tests hold the cost per character flat from 16 KiB to 128 KiB.
101
+ - **The lexer agrees with real shells.** The oracle (`DS_ORACLE=1`) finds no construct the lexer calls closed that `bash -n` or `dash -n` reports open.
102
+
103
+ ### How it is verified
104
+
105
+ - **The oracle** (`tests/test_oracle.py`, opt-in, Docker): seeded candidates run under the container's real `bash` and `dash` with stub `curl`/`wget` and stub programs that only log, no network, no capabilities, a read-only root, and a canary download before and after each batch. Four properties: recall in code files, recall in prose (whatever a shell really runs stays `critical`), the lexer against `bash -n` / `dash -n`, and soundness of the softening (whatever is reduced to `medium` fetches nothing but a known installer and calls nothing else). A new spelling goes into `tests/oracle/grammar.py` first. The oracle sees under-detection and unsafe softening, never extra alerts, and does not cover PowerShell, zsh, or effects only the real installer would show; guards that only add alerts are pinned by unit tests instead.
106
+ - **Unit pins.** `tests/test_linewide.py` pins the floor against its source rule; `tests/test_gate_edges.py` pins the gate, rendered view and shield patterns by the shortest texts on which a mutant of each differs; `tests/test_enclosure.py`, `tests/test_shelllex.py` and `tests/test_metadata_rules.py` pin the rest end to end. A mutant that no text tells from the code is an equivalent one (an alternative another covers, a possessive quantifier, a class member under `re.IGNORECASE`, a cost bound).
107
+
108
+ ## Other prose softening
109
+
110
+ All in `indicators._soften_prose`, beside `invisible-unicode` in right-to-left text (above); a code file and a branch are never softened here (the file scope below changes `command-substitution` in source code and restores the phrase in agent files).
111
+
112
+ - **`prompt-injection`** whose only trigger is the bare phrase "system prompt" is `medium`: ordinary in a thread about an LLM product. It is `high` when:
113
+ - a real override phrase is present (`ignore previous instructions`, `your system prompt`, `print the system prompt`, `new instructions:`, and the previous / prior / earlier / the above family), which must name what is dropped (instructions, rules, guidance, context) or ask for an action (approve, merge, lgtm, reply, print, reveal, send, exfiltrate, grant, delete, execute). "Ignore all previous commits" and a bare "ignore the above" are ordinary sentences;
114
+ - an imperative starts a sentence aimed at the phrase (`Ignore the system prompt ...`, `New system prompt: ...`): the verb follows the start of the text or a line, punctuation, a quote, a markdown or diff marker, a table pipe, a bullet, a vocative, or `could you` / `can you` / `you need to` / `you will`; never a hyphen, an apostrophe or a word;
115
+ - it is addressed to a reviewer or a model: `please`, `you must/should`, or a name (`agents`, `Claude`, `the AI`, `reviewers`) with a directive modal, then the verb. A name without a modal describes them. Not through a negation.
116
+
117
+ It stays `medium` when the verb starts a library title with a subordinate clause (`Ignore system prompt when counting tokens`), when the phrase starts a longer name (`system prompt caching`) or is a whole table cell, and when negated, except that a clause about the review itself (`when reviewing this PR`, `and merge`) makes it an instruction. A variable (`SYSTEM_PROMPT`), a flag or a path is not the phrase.
118
+ - **`pwn-request`** is `medium`: a workflow shown in a body, a comment or a documentation file is not one that runs. The same text in a workflow file stays `critical`.
119
+ - **`diff-viewport-evasion`** is `medium` in a comment only (a padded markdown table). In a file, including a markdown file, it stays `high`.
120
+ - A planner re-scan of text that is not byte-identical to the baseline's window mints another id (a title is scanned with the author appended by the baseline), and the stricter baseline hit for a code file always stays in the union.
121
+
122
+ ## The file-structure rules (`scan_files`)
123
+
124
+ A dev machine infected by a loader of the Contagious Interview / PolinRider family pushes its payload with ordinary work: obfuscated JavaScript appended to a config that runs on lint or build, a "font" that is a script, and a way to run it nobody types (a VS Code task that runs on folder open, an npm script now starting with `node <new file> &&`). `scan_files` reads what only the file list knows; `scan_change` runs it beside `scan_content` and `scan_metadata`.
125
+
126
+ - **`package.json` and the VS Code files are read as JSONC tokens, never line by line** (`_jsonc_tokens` over the new side of the patch, each token marked written or context). A comment between a key and its value, a value on the next line, a `\u` escape, a one-line `"scripts": {...}`, a task's `args` array and `allowAutomaticTasks` nested under `"task"` are ordinary spellings, and a line regex misses each of them. A hit needs a token the change wrote.
127
+ - **It pairs a run entry with the change's file LIST, not with another file's text.** `run-entry-new-file` asks whether a `package.json` script, or a VS Code task `command` with its `args`, runs a file whose status is `added`. That is a structural fact, so it does not break "a paired rule only pairs within one file". A target resolves against the `package.json` directory, and against the parent of the `.vscode` folder for a VS Code file.
128
+ - **`run-entry-new-file` is `medium` alone**: a codegen script and its npm entry arrive together in honest work. It is `high` for an npm lifecycle hook (`preinstall`, `install`, `postinstall`, `prepare`, `prepublish`, `prepublishOnly`, `prepack`, `postpack`), for a task in a file that holds a folder-open task (written now or already there), and when the new file is put in front of an existing script left unchanged behind it (`node api.js && <the old command>`, the way a payload rides an ordinary command). Repointing a script at a new file or appending a step after the old command is ordinary refactoring and stays `medium`: on the live watchlist a fork-sync PR of upstream refactors did both.
129
+ - **What a run entry runs** (`_run_targets`): each command of the line (split at `;`, `&`, `|`, parentheses) whose program, after any `VAR=x` and wrapper (`npx`, `env`, `command` and the like), is a runtime (`node`, `bun`, `deno`, `tsx`, `python`, `bash`, `sh`, `pwsh`, `cmd` and the like; case-sensitive, except a Windows `.exe`) runs its first word that is not a flag (`-x`, or `/x` for `cmd` and `start`) and not itself a runtime (`cmd /c node x.js`); a word holding a whole command (`bash -c "node x.js"`) is read as one. Known structural gaps, each still caught by the text rule when the words share one string: `start "" node x` (the empty title), `deno run x` / `bun run x` (the subcommand), and a flag that takes a value (`node --stack-size 4000 x`). Other commands of the line (`cp logo.png dist/`) and later words (the image a script is given) are arguments. A VS Code task's `command` may be a string or the `{"value": ...}` quoting form, and its `args` are read wherever they sit in the same object.
130
+ - **`asset-run-as-code` is `critical`**: a runtime handed a font or image file, as a text rule (`node x.woff2` in any text, the planner's scan included; the runtime word is lower case and the extension must end the path) and as a structural one over the run entries above.
131
+ - **`vscode-autorun` is `high`**: a written `"runOn": "folderOpen"` in a `.vscode/` file or a `.code-workspace`, and a written `task.allowAutomaticTasks` set to `true` or `"on"`, which turns automatic tasks on for the workspace (VS Code planned to make `off` the default). A maintainer adding a folder-open watcher task is an accepted over-alert: the setting runs code on open, the campaign's own trigger.
132
+ - **`binary-name-holds-text` is `high`**: a text patch under a font, image, archive or native-binary name, or added lines with no patch (a text diff too large to show). A real file of those formats has a NUL in its first bytes, so GitHub sends no patch and counts no lines. A Git LFS pointer is exempt only when its whole new side is pointer keys (`version` first, then `oid sha256:<64 hex>`, `size <n>`), so an update that rewrites only `oid` and `size` stays exempt and a script with a pointer line inside does not. `.svg` is text by nature and `.pdf` is often stored as text, so neither is on the list.
133
+ - **`obfuscated-dev-config` is `critical`**: `obfuscated-string-array` in the ADDED lines of a config a developer's tools execute (`eslint`, `vite`, `vitest`, `webpack`, `rollup`, `next`, `nuxt`, `tailwind`, `postcss`, `babel`, `jest`, `playwright`, `prettier`, `hardhat`, `commitlint`, `stylelint` configs and their `rc` files, `truffle-config`, `gatsby-*`, `.mocharc`, gulpfile, Gruntfile, `karma.conf`). Removing it is cleanup.
134
+ - **The token walks are bounded**: a comment never spans two hunks (the lines between them are unseen), and a `command`'s object is searched at most `_OBJECT_REACH` tokens each way, for at most `_MAX_RUN_ENTRIES` entries a file.
135
+ - **A structural hit's id carries its path** (`form=path`): the same setting in `tasks.json` and `settings.json` is two findings, not one deduped into the other.
136
+ - **`obfuscated-string-array` (`high`) runs everywhere**: javascript-obfuscator's rotation loop `while(!![])` with the push/shift rotation or 20 hex-indexed decoder calls. It needs no `_0x` names, which a mangled-name setting removes (`obfuscated-identifiers` still reads those). Hand-rewritten output (`while(!0)`) is a known gap.
137
+ - **Measured:** over 857 live changes (6,858 files from four large repositories) the rules fired only on the one change they were written for, and the string-array signature appeared in none of 3,553 JavaScript files over 30 KB (bundles and minified libraries among them). Terser and esbuild write `!0` and `for(;;)`, never `!![]`.
138
+
139
+ ## What the kind of a segment changes
140
+
141
+ `_scope_to_file` runs on every segment of an event but the branch name (`scan_content`) and on every file of a diff (`scan_diff`), after the detectors and the prose softening. A branch name keeps every hit, and so does the planner's `scan_indicators`, but for a maintainer's `inline-code-exec` it copies (`_written_by_maintainer`, above).
142
+
143
+ - **`command-substitution` in prose** (a body, a comment, a documentation file) is a `$(...)` or a backtick span that reads as a shell's (`_SHELLISH_SPAN_RE`: an operator `|`, `;`, `&`, `<(`, or a `$` expansion); a plain backtick span is Markdown's code formatting. A newline and `>` are not counted: a span that crosses a code fence and a placeholder such as `<install folder>` hold them in ordinary issue text. The hits are rebuilt from those matches only (`_prose_substitutions`, on the `_lf` text the shell rules read), so the evidence shows what a shell would run. The backticks of each line are also paired among themselves, and on a line with an odd number of them every one is also read as an opener, since a stray one shifts the pairing of the rest. An escaped backtick (`` \` ``) pairs with nothing: it is a literal one to a shell and to Markdown. A title keeps every substitution, because a workflow that interpolates it into `run:` executes backticks as it executes `$(`. A body or a comment can be interpolated too, but there a span without an operator is code formatting nearly every time, and a `$(...)` still counts. A notebook (`.ipynb`) is not prose for this rule: a `!cmd` or `%%bash` cell runs in a shell.
144
+ - **`command-substitution` in source no shell reads** (`_NON_SHELL_SOURCE_EXTS`: JavaScript, TypeScript, Vue, Svelte, Python, Go, Java, Kotlin, Scala, C#, F#, Rust, C, C++, Objective-C, Swift, Dart, Lua) counts only when the file also calls a shell or starts a program (`_EXEC_SINK_RE`: `child_process` or a `require`/`import(` whose name is built with `+`, `execSync`/`execFile`/`spawn`, zx's `` $` ``, `@actions/exec`, shelljs's `shell.exec`, `os.system`/`os.spawn*`, `subprocess` and `create_subprocess_*`, `exec.Command`, `syscall.Exec`, `ProcessBuilder`, Dart's `Process.run`, `system(`). A template literal is not a shell's substitution. Ruby, Perl and PHP are not in the list, because their backticks run a shell, nor are Groovy and Gradle's `.kts` (a Jenkins `sh` step, a Gradle `exec` block); neither is any file a shell reads (scripts, Dockerfiles, workflows, Makefiles, `package.json`, Markdown) or a file of an unknown kind. In real campaigns `$(...)` arrived in branch names, file names and titles: a branch name stays unscoped, a title keeps every substitution, and a file whose name holds one keeps all of its own. A bare `exec(` is not a sink (it is a SQL or a local helper as often as `child_process.exec`), so a hunk that calls a destructured `exec` without showing its `child_process` import is a known gap.
145
+ - **`inline-code-exec`** by the writer of a body or comment (above).
146
+ - **`remote-fetch-to-disk` has no loopback exemption.** A fetch of `localhost` that writes to disk stays `high`: an infostealer reads DevTools (`9222`, `9229`), the Docker API and the kubelet there, and each test that tells a local service from those (the port, an option that overrides the URL, a second client, a raw socket, a computed `fetch`) has a spelling around it.
147
+ - **The bare phrase "system prompt" in source code stays `high`.** A closed list of names ("the system prompt section") cannot soften it: every test that tells a name from an injection (a comment, a docstring, an order word, the lines around it) has a spelling around it, and the Nx compromise put its agent prompt in a string literal. A benign mention there is the model stage's to judge.
148
+ - **A file an agent reads as its instructions** (`_AGENT_FILE_RE`: `AGENTS.md`, `AGENT.md`, `AGENTS.*.md`, `CLAUDE.md`, `GEMINI.md`, `CONVENTIONS.md`, `.cursorrules`, `.windsurfrules`, `.clinerules`, the `.cursor/`, `.windsurf/`, `.roo/`, `.claude/`, `.junie/`, `.kiro/`, `.amazonq/`, `.gemini/`, `.continue/` and `.codex/` directories, `.github/copilot-instructions.md`, `.github/instructions/`, `.github/prompts/`, `.github/agents/`, `.github/chatmodes/`, `*.prompt.md`, `*.agent.md`, `*.chatmode.md`, `*.instructions.md`, `*.mdc`, `.mcp.json`) never softens that phrase, though it is Markdown: any change to such a file is aimed at an agent.
@@ -0,0 +1,44 @@
1
+ ---
2
+ paths:
3
+ - "diff_sentry/watch.py"
4
+ - "diff_sentry/actors.py"
5
+ - "diff_sentry/snapshot.py"
6
+ - "diff_sentry/ingest.py"
7
+ - "diff_sentry/hosts.py"
8
+ - "diff_sentry/dbutil.py"
9
+ - "tests/test_watch.py"
10
+ - "tests/test_actors.py"
11
+ - "tests/test_snapshot.py"
12
+ - "tests/test_hosts.py"
13
+ - "tests/test_ingest_metadata.py"
14
+ - "tests/test_metadata_rules.py"
15
+ - "tests/test_campaign_replay.py"
16
+ - "tests/test_cli_watch.py"
17
+ - "tests/corpus/hackerbot_claw_campaign.json"
18
+ ---
19
+
20
+ # Monitoring: the watcher, the actor store, the archive
21
+
22
+ - **All of it is host-side.** `watch`, `actors`, `snapshot` and the ingest enrichment run outside the trajectory. The planner never queries the store, the queue, or GitHub. The one thing it receives is the `actor_history` summary (repositories, outcomes, rule names, hours, and which profile FIELDS changed, never the profile text), rendered as an untrusted section because the logins and refs in it came from users.
23
+ - **Cross-event facts are provenance, not a second signal.** `ActorStore.correlate` is read-only and runs BEFORE `record`. Its output lands in `event["provenance"]` under plain keys (`actor_*`, `ioc_reuse`, `content_revisions`, `evidence_scrubbed`) and `indicators.scan_provenance` turns them into baseline hits. Never add a hits or signal path from the store. A change scanned on its own carries none of those keys and fires none of those rules.
24
+ - **A deleted account is not an actor.** GitHub's `ghost` is one identity for every deleted user, so `actor_key` keys a `ghost` change on the change itself: two unrelated deleted authors never share a history.
25
+ - **Examine oldest first.** Correlation reads what came before a change, so the listing (newest first) is sorted ascending before it is examined. A burst examined newest-first would show its last member nothing.
26
+ - **Attribution follows the author of the text, decided structurally.** `IndicatorHit.via` names the commenter and is set from the segment KIND in `normalize.content_segments_ex`; it is never parsed out of `location`, which is a free-form file path an author can name `comment:alice`. A hit whose `via` is another account is recorded against that commenter (`channel='comment'`, only when it reached the floor or fired a slash command), and `correlate` reads only the author's own hits, so a drive-by's payload host does not link the maintainer's next change and a moderator deleting the comment is not the author scrubbing evidence. A verdict on a change whose floor-level evidence is only a third party's goes to that party (`finalize`).
27
+ - **A verdict clears an inference, never evidence.** (`actor-known-bad` is the one model-derived fact that enters the baseline: it needs a stored `malicious` verdict, expires after 30 days, and does not apply to `Bot` accounts.) A `benign` verdict clears the account-level flag (`actor-prior-flag`, an inference about a person), except over a prompt injection, where the verdict may be the injection working (`ActorStore._stands`, the only implementation). It never clears `ioc-reuse`, the payload-host link: a local model has a real false-benign rate, and letting its verdict erase evidence would let one missed payload launder a campaign. The account-level rules do not apply to `Bot` accounts; the change-level ones do.
28
+ - **The first copy is kept.** `SnapshotStore.put` runs before anything else touches a change; objects are never rewritten and the index is append-only. An edit is a new revision with its own run id (`-r1`), so its trace never overwrites the original's, and `prior_indicators` carry the earlier hits forward so a removed payload cannot remove the evidence. `evidence-scrubbed` fires when a floor-level hit of an earlier revision is gone (matched by rule and location, not id; a payload rewritten to read as prose counts as gone) and names the vanished hit ids, so a later edit that still lacks them is the same finding and does not page again. Snapshot the event as ingested, before correlation attaches facts, so the archive holds what GitHub said and not what we derived.
29
+ - **Time is the cost, not money.** With a local model every change reaches the model by default; the screen sets the order (`_priority`), not eligibility. The caps (`deep_min_severity`, `max_queue`, `max_deep_per_hour`) default off. A shed change keeps its `screened/` record and a `shed` state.
30
+ - **The screen is not a trajectory.** `cli.screen` writes `screened/{run_id}.json`, never a trace, so the rollout corpus stays trajectories. Its early alert is `stage=screen`; the run's own is `stage=final`.
31
+ - **A run never shares a thread.** dspy's global configuration is bound to the thread that first set it up. With `workers=1` the worker thread runs the model itself; with more, each run goes to a `spawn` process of a `ProcessPoolExecutor` (its own interpreter and dspy), and the worker thread keeps every read and write of the state (claim, settle, finalize). Never run the model in a thread pool. A child that dies breaks the pool: every run in it counts as a failed attempt and the pool is rebuilt; a run that finished first keeps its result. A stopped pool terminates its children and leaves their rows claimed for `recover()`; an unexpected error settles them as a failed attempt, so a long-lived watcher strands nothing. A claim or a submit that fails touches its own row only. Whatever a child's future raises, a Ctrl-C included, is that run failing, never the worker thread's end. A child exits as soon as its watcher is gone (`_child_init`), so a watcher killed outright cannot leave a run going while a restart runs the same run id again. A run sets the change's state only while it is the change's latest revision (`_mark`). An examined change's row, its queue row and the change shed to make room are written in one transaction (`record_examined`): a change never reads `queued` without a queue row (the next poll would call it unchanged and never run it), and a worker never claims a change without a row. A claim that cannot be followed through is given back (`unclaim`). A stop or a Ctrl-C leaves the runs it cuts off claimed; only an error settles them. More than one worker needs a runner and poster that pickle, checked before any file is opened. The poller is a separate thread and touches no model.
32
+ - **A failed change must not block the ones behind it, and must be retried.** A failure holds the watermark below it and withholds the page-1 ETag (otherwise the next listing answers 304 and it is never retried), backs off exponentially, and after `_MAX_FAILS` is parked as `error` so it stops holding the listing. The others are skipped by their stored `updated_at`. A listing deeper than `_MAX_PAGES` is reported as truncated, never advanced past silently. A rate-limit refusal (`429`, or `403` saying so) is not a failure of the change: wait for the reset, and `_ingest` subtracts the calls it made from the budget the list responses report.
33
+ - **A read that failed is not a change.** `_comments` returns None on failure and the event carries `comments_incomplete`; the watcher then keeps what it has instead of judging the empty thread as an edit. Anything that feeds the digest must distinguish "none" from "could not read".
34
+ - **Run ids are unambiguous.** `owner__name-pr-7` (`-r1` per revision). Owners cannot contain `_`, so two repositories never collide; the one-off `pr`/`issue` commands keep the `owner-name-pr-7` scheme, which can collide.
35
+ - **A failed model run is retried, and pages nothing.** It backs off up to `_MAX_ATTEMPTS` (about four hours, so a local model restarting does not fail the queue) with `cli.run` told `emit_failed=False`, then is buried (`claimed=2`, event kept) for `--requeue-failed`. Claiming counts as an attempt, so an input that kills the process cannot loop across restarts. The queue row stays claimed until the run completes, and `recover()` (called by `serve` and `--once`, never by a read-only look) requeues it under a per-directory `flock`. Neither thread may die on an unexpected exception.
36
+ - **Undelivered alerts are retried a few at a time.** `_retry_alerts` re-sends at most `_ALERT_RETRIES_PER_POLL` (5) per poll, so a SIEM that hangs (a POST is bounded by 15 s) cannot stall polling for a whole backlog.
37
+ - **An alert counts once delivered.** `changes.alerted` gains ids only after the SIEM answered 2xx (`EmitResult.ok`); a refusal or outage leaves `alert_pending`, re-sent from `screened/{run_id}.json` on later polls. `emitted` alone is also True for a 5xx.
38
+ - **State files are versioned.** `dbutil.open_versioned` stamps `PRAGMA user_version` and refuses a file with tables but another stamp; there is no migration, and the error says to move the directory aside.
39
+ - **Account age is measured as of the event.** `_age_days(created_at, as_of)` takes the event's own time; the wall clock is only the fallback for a payload with none. A replay of a historical incident must carry `created_at`, or every old attacker looks established (`test_campaign_replay` pins this).
40
+ - **Attacker-authored strings stay out of the MF1 sandwich.** Profile fields, branch names, and comments are bounded at ingest and rendered in the untrusted section only. Comments are text-scanned only when the change's author or an outsider wrote them and the commenter is not a `Bot`: maintainers quote install commands and release notes constantly.
41
+ - **Data minimization.** The `email` fields of a profile and of commits are reduced to a domain in the event and masked in the archive (`snapshot.scrub_emails`). Free text is archived as captured: it is evidence. Nothing prunes the archive, the store, or queued events.
42
+ - **Adding a fact:** capture it in `ingest.py` (best-effort: a failed call omits the key, unless the value feeds the digest, in which case mark the event incomplete instead of reporting "none"), put it in `provenance` (or a bounded event field), read it in `scan_provenance` / `scan_metadata` with a negative case, and keep it sub-floor unless it is standalone evidence. Replay the incident corpus.
43
+ - **Read-only means read-only.** `--stats` and `actor` open the state `mode=ro` (`open_versioned(read_only=True)`) and neither create, stamp, nor requeue anything. Only `serve` and `--once` call `recover()`, under the lock.
44
+ - **Terminal output is sanitized.** `format_dossier` replaces control characters; `_json_out` escapes the C1 range `json.dumps` leaves raw; URL and token parsing cut at the first control character. A string an attacker wrote must not be able to drive the operator's terminal.
@@ -0,0 +1,26 @@
1
+ ---
2
+ paths:
3
+ - "diff_sentry/rubric.py"
4
+ - "diff_sentry/rl_export.py"
5
+ - "diff_sentry/response.py"
6
+ - "diff_sentry/assemble.py"
7
+ - "eval/**"
8
+ - "studio/**"
9
+ ---
10
+
11
+ # ATLAS rubric labels and the eval member
12
+
13
+ ## The rubric is a reward-free label surface
14
+
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
+ - 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
+ - `CriterionFact` has no score/met field; the trainer scores. Never add a reward/score/met field to any rubric type.
18
+ - `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
+ - `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.
20
+
21
+ ## The eval member measures; it never rewards
22
+
23
+ - `eval/` (`diff-sentry-eval`) scores a recorded run's assembled verdict with a fixed external ATLAS LLM-as-judge: TF/TA/TG/PA on a 0 to 10 scale, TF primary, per-category means only, no composite, no threshold.
24
+ - It is a one-way reader: it reaches diff-sentry only through `verdict_from_events` / `run_labels` / `run_metrics` / `AssembledVerdict`, and `diff_sentry` never imports `diff_sentry_eval` (`eval/tests/test_boundary.py` enforces it).
25
+ - The judge is rubric-free (it never reads `rubric_signal`, which would bias the measure) and holds read-never-execute: it assesses the classification statically and treats the change as untrusted.
26
+ - A run it cannot score (never finalized, no usable verdict, judge failed) is `unscored`, never a fake 0. See `eval/README.md`.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: release
3
- description: Cut a diff-sentry release — bump the five version sites together, write the CHANGELOG entry, and publish the GitHub Release that ships PyPI + the Marketplace Action. Use when asked to release, bump the version, or tag vX.Y.Z.
3
+ description: "Cut a diff-sentry release: bump the five version sites together, write the CHANGELOG entry, and publish the GitHub Release that ships PyPI + the Marketplace Action. Use when asked to release, bump the version, or tag vX.Y.Z."
4
4
  ---
5
5
 
6
6
  # Cut a diff-sentry release
@@ -24,7 +24,7 @@ check it by hand.
24
24
  CHANGELOG candidate.
25
25
  2. Write the `CHANGELOG.md` entry in the file's existing style: one sentence per user-visible outcome,
26
26
  breaking changes and required actions explicit, no debugging story.
27
- 3. Bump sites 1–4, run `uv lock`, then verify: `uv run pytest`, the eval suite
27
+ 3. Bump sites 1 to 4, run `uv lock`, then verify: `uv run pytest`, the eval suite
28
28
  (`uv run --package diff-sentry-eval --extra dev python -m pytest eval/tests`), `uvx ruff check .`.
29
29
  4. Commit (`chore: release X.Y.Z`) and push to `main` once the user approves. CI's `consumer install
30
30
  path (PyPI)` job then goes RED on this commit, by design: it installs `action.yml`'s default version
@@ -0,0 +1,81 @@
1
+ # diff-sentry: model roles and runtime knobs.
2
+ # Copy this file to `.env`; a live run needs these plus a Deno sandbox.
3
+
4
+ # --- Model roles (required for a live `pr`/`issue`/`classify` run) ---
5
+ # planner: a cheap, injection-resistant orchestrator that holds the diff in the REPL and triages.
6
+ DS_ROOT_LM=your-planner-model
7
+ # analyst: an expensive model reached via llm_query for a subtle source->sink (short questions only).
8
+ DS_SUB_LM=your-analyst-model
9
+ # One OpenAI-compatible proxy serves all roles.
10
+ # When DS_BASE_URL is set, the model names are the plain ids your endpoint serves (e.g. qwen/qwen3-next), not litellm-prefixed.
11
+ DS_BASE_URL=https://your-openai-compatible-endpoint/v1
12
+ DS_API_KEY=sk-...
13
+
14
+ # --- OR: run planner + analyst on a Claude Pro/Max subscription (no API key) ---
15
+ # A role with the `claude-agent-sdk/<id>` prefix runs on your Claude subscription through the Claude Agent SDK (`rlm_harness.ClaudeAgentLM`, the `subscription` extra).
16
+ # Each call is a pure completion: no tools, no filesystem, no settings leakage.
17
+ # Prerequisites:
18
+ # - `uv sync --extra subscription`
19
+ # - the Claude Code CLI installed and logged in (`claude` then /login, or `claude setup-token` and export CLAUDE_CODE_OAUTH_TOKEN)
20
+ # - `unset ANTHROPIC_API_KEY`: the CLI prefers it over the subscription and would bill API credit, so the adapter refuses to start while it is set
21
+ # Prefer full model ids over aliases, which drift.
22
+ # DS_ROOT_LM=claude-agent-sdk/claude-sonnet-5 # planner on Sonnet 5
23
+ # DS_SUB_LM=claude-agent-sdk/claude-fable-5 # analyst on Fable 5
24
+ # DS_BASE_URL/DS_API_KEY then serve only the classifier, which always needs an OpenAI-compatible endpoint (a subscription-only run is not supported).
25
+ # Set DS_CLASSIFIER_LM to a real model id (plus DS_CLASSIFIER_BASE_URL/DS_CLASSIFIER_API_KEY if it is a separate box).
26
+ # Left unset, it inherits the `claude-agent-sdk/…` DS_SUB_LM and config loading fails with an actionable error.
27
+ # Budgets use the same env vars as proxy mode.
28
+ # Each call adds ~2-5s of CLI-spawn overhead, so keep these runs interactive, not batch.
29
+ # DS_PLANNER_MAX_TOKENS has no effect here: the Agent SDK exposes no token or sampling controls.
30
+
31
+ # --- Second-stage classifier (the swappable seam) ---
32
+ # Defaults to the analyst model on DS_BASE_URL/DS_API_KEY.
33
+ # Required when the analyst runs on the subscription (see above).
34
+ # DS_CLASSIFIER_LM=your-strong-classifier
35
+ # DS_CLASSIFIER_BASE_URL=https://your-classifier-endpoint/v1
36
+ # DS_CLASSIFIER_API_KEY=sk-...
37
+ # DS_CLASSIFIER_CIRCUIT_BREAK=4 # unusable replies in a row before the tool stops calling it
38
+ # DS_CLASSIFIER_TIMEOUT=60 # seconds per call, a hard ceiling
39
+ # DS_CLASSIFIER_TRANSIENT_RETRIES=1 # re-sends after an endpoint error (not after an unusable reply)
40
+ # DS_CLASSIFIER_MAX_TOKENS=2048
41
+ # A reasoning model can think through the whole budget and answer nothing; the tool then says it stopped at max_tokens.
42
+ # Raising the cap alone can turn that into a timeout on a slow model; a thinking budget bounds both.
43
+ # Extra request parameters (JSON object, single-quoted) apply over the default temperature of 0.1.
44
+ # Model, messages, stream, n, timeout, max_tokens and max_completion_tokens are refused (the settings above own them).
45
+ # So is a key the OpenAI client does not take: a parameter only your server knows goes under extra_body.
46
+ # DS_CLASSIFIER_LM_KWARGS='{"temperature":1,"extra_body":{"thinking_token_budget":1024}}'
47
+
48
+ # --- RLM runtime ---
49
+ # DS_INTERPRETER=pyodide # read-not-execute: the sandbox never runs the change. `deno` is an alias.
50
+ # DS_MAX_ITERATIONS=25
51
+ # DS_MAX_LLM_CALLS=8 # caps only analyst (llm_query) escalations
52
+ # DS_ADAPTER=json # portable structured output (OpenAI + vLLM/NIM)
53
+ # DS_PLANNER_MAX_TOKENS=16384 # generous, so a reasoning planner isn't truncated to empty content
54
+ # Extra request parameters per role (JSON object, passed as-is; single-quote it, because `uv --env-file` strips double quotes).
55
+ # These alias rlm-harness's RLM_MAIN_LM_KWARGS / RLM_SUB_LM_KWARGS, which are read too; setting both to different values is refused.
56
+ # A reasoning planner that thinks through the whole cap answers nothing and the run fails; on vLLM a thinking budget below the cap prevents that:
57
+ # DS_ROOT_LM_KWARGS='{"extra_body":{"thinking_token_budget":8192}}'
58
+ # DS_SUB_LM_KWARGS= # the analyst only; the classifier builds its own request
59
+ # A `max_tokens` here overrides DS_PLANNER_MAX_TOKENS; model, endpoint and timeout keys are refused.
60
+
61
+ # --- Enrichment fetch (off by default: an exfil surface on attacker-authored input; GitHub-allowlisted) ---
62
+ # DS_ENABLE_FETCH=false
63
+ # DS_FETCH_ALLOW_CIDRS=198.18.0.0/16 # only if behind a fake-IP proxy / split-DNS VPN
64
+
65
+ # --- Host-side SIEM emitter (opt-in; deterministic POST after the run) ---
66
+ # DS_SIEM_WEBHOOK_URL=https://your-siem/intake
67
+ # DS_SIEM_TOKEN=...
68
+ # DS_EMIT_ON=suspicious,malicious
69
+
70
+ # --- Watching a watchlist (`diff-sentry watch`) ---
71
+ # Time is the only cost with a local model, so the caps are OFF unless set.
72
+ # DS_WATCH_REPOS=owner/name,other/name # or --repos / --repos-file
73
+ # DS_WATCH_STATE_DIR=./output/state # watch.db, actors.db, snapshots/
74
+ # DS_WATCH_INTERVAL=60 # seconds between polls (raised if GitHub sends X-Poll-Interval)
75
+ # DS_WATCH_BACKFILL_HOURS=0 # on first sight of a repo, also examine changes this recent
76
+ # DS_WATCH_RECHECK_HOURS=24 # shorten the re-check schedule (15 min, 1 h, 6 h, 24 h); it cannot extend it
77
+ # DS_WATCH_DEEP_MIN_SEVERITY=info # info = every change goes to the model; raise it to gate on the screen
78
+ # DS_WATCH_MAX_QUEUE=0 # 0 = unbounded; otherwise the lowest priority is shed and recorded
79
+ # DS_WATCH_MAX_DEEP_PER_HOUR=0 # 0 = unlimited model runs per hour
80
+ # DS_WATCH_WORKERS=1 # model runs at once; above 1, each in a process of its own
81
+ # DS_WATCH_RATE_RESERVE=200 # pause polling when the API's remaining budget falls below this
@@ -0,0 +1,58 @@
1
+ # diff-sentry: agent guide
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` has the pipeline table and the honest caveats; rlm-harness's "Building a consumer" is the extension contract this project lives within.
4
+
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
+
7
+ ## Verify
8
+
9
+ - `uv run pytest`: the full offline suite (`uv sync --group dev` first). No live LLM, network, or Deno: dspy paths use DummyLM / rlm-harness's `ScriptedInterpreter`, transports are injected fakes, and `tests/corpus/` pins the indicator suite's hit/miss behavior.
10
+ - `uv run --package diff-sentry-eval --extra dev python -m pytest eval/tests`: the eval member's own suite (the `--package` is required; a root `uv run` does not install the member). Run it when you touch `assemble.py`, `rl_export.py`, `rubric.py`, `schema.py`, or the trace payloads it scores.
11
+ - `DS_ORACLE=1 uv run pytest tests/test_oracle.py`: the shell oracle (opt-in; needs Docker and the `python:3.11-slim` image): candidate download-and-execute texts are run under a real `bash` and `dash` in a no-network, cap-dropped container with a stub `curl`, and the rules are held to what actually ran (`DS_ORACLE_N` / `DS_ORACLE_SEED` scale it). Run it when you touch the `curl-pipe-shell` patterns, the prose softening in `indicators.py` (`_soften_prose` and the helpers it calls, the shape gate, the rendered view), `hosts.py`, `linewide.py`, or `shelllex.py` (the lexer behind the enclosure check, held to what `bash -n` and `dash -n` say about generated fragments).
12
+ - `uvx ruff check .`: lint (line-length 110). CI runs it as its own job, next to both suites and the node tests (`.github/workflows/ci.yml`).
13
+ - A live run needs role creds (`DS_*`, see `.env.example`), Deno (`brew install deno`), and `gh` for `pr`/`issue` ingest. `render`/`export` are offline; `classify` ingests offline but classifies live.
14
+
15
+ ## Running: always through the CLI
16
+
17
+ - Run via `cli` (`pr` / `issue` / `classify`), never an ad-hoc script. `cli.run(event, …)` is the programmatic entry: it records `<out>/traces/{run_id}.jsonl` (dropping a stale one first, since TraceRecorder appends), writes `<out>/responses/{run_id}.json`, and emits the SIEM signal host-side after the run. It never raises: a crash still writes a `status=failed` response that can signal off the evidence floor. Extend `cli.py` rather than driving `detect_from_event` / `build_response` directly.
18
+ - Offline: `python -m diff_sentry render <trace> <run_id>` re-renders a response; `python -m diff_sentry export "output/traces/*.jsonl" ds.json` exports the reward-free dataset; `actor <login>` prints an account's dossier and `archive verify` re-hashes the snapshot archive.
19
+ - Continuous collection goes through `watch`, which calls `cli.run` (model) or `cli.screen` (deterministic, no creds) and never `detect_from_event` directly. `--screen-only` needs no model roles.
20
+
21
+ ## Hard invariants: do not break
22
+
23
+ - **The change is DATA: read, never execute.** It is a REPL variable under the default `pyodide` interpreter; nothing in it is run, built, or fetched-and-run. An embedded instruction is a `prompt-injection` signal, never a command. Never route the interpreter to `local`.
24
+ - **Judgement-only SUBMIT; evidence is unioned on read (MF3).** `ChangeVerdict` has no hits field; the planner may only CITE ids (`indicator_ids`). `assemble.assemble_verdict` unions every hit in the trace (run_start `baseline_indicators` ∪ each `scan_indicators` tool_call) and derives `signal = verdict ∈ emit_on OR max severity ≥ SIGNAL_SEVERITY_FLOOR`. A false-benign verdict cannot suppress evidence; a cited id with no recorded hit lands in `cited_unknown_ids`. The floor reads GROUNDED hits only (`floor_indicator_severity`; `max_indicator_severity` reads every hit). The baseline is always grounded. A planner scan is grounded when its region is copied from the change, decoded from a span the change holds (`decoded_from`), or decoded from a value an earlier call of the run decoded (each layer of a multi-layer encoding; the run remembers only decoded values, by digest for the whole run and as text newest-first for a layer cut from a slice). The tool decides this, because it holds the change, and records it on the call. Nothing requires that span to look encoded: the change's author picks the encoding. A hit on text the planner wrote stays in the evidence, marked `grounded: false`, and never lifts a grounded sighting of the same id; a trace without the field reads as grounded. The same assembly runs on every read path (live, re-render, `rl_export`). Never add a hits/severity/signal field to the SUBMIT type or a second signal derivation.
25
+ - **An ungroundable input yields `inconclusive`, never a confident verdict.** `inconclusive` is a sanctioned 4th SUBMIT value (`schema.INCONCLUSIVE_VERDICT`) mapped to `status="inconclusive"` + `RefusalInfo(reason="insufficient_evidence")`. The host-side backstop `normalize.has_groundable_content` downgrades even a confident verdict when no groundable content exists. It is a reward-free negative outcome, not an escape hatch for a hard-but-real change, and not in `emit_on`, so hard indicator evidence still signals on its own. The converse is enforced in the loop: on a change with groundable content and nothing GitHub withheld (a patch, or files past its list cap), `ClassifyChange` swaps in `schema.decisive_verdict_model`, which refuses an `inconclusive` SUBMIT twice with a message naming the content; dspy hands the refusal back as the step's output, so the planner decides in the same trajectory (no retry), and the third stands. dspy's extract fallback at the iteration cap runs under `schema.no_refusal` (`detect._FinalExtract`): no turn is left there to answer one.
26
+ - **MF1: the metadata sandwich in `normalize_event`.** dspy previews ~1000 chars of head+tail, so an identical derived-metadata header and footer deny the attacker those edges, and attacker-authored metadata fields are capped (`_MAX_META_*`). The title/author ride in `raw_content` so the host-side baseline catches a title-borne injection that skews the planner. Don't flatten the sandwich or lift the caps.
27
+ - **MF2: enrichment fetch is GitHub-allowlisted and off by default** (`enable_fetch=False`). An injected instruction could steer a fetcher to `https://attacker.tld/?leak=…`: the kit's SSRF guard blocks internal targets, and the `github_hosts` allowlist blocks external ones, re-checked on every redirect hop. Never add a general-purpose fetch or fetch a URL the change names. `fetch_allow_cidrs` only relaxes the resolved-IP layer (fake-IP proxy / split DNS); the syntactic guard still refuses localhost/metadata.
28
+ - **Indicators are deterministic, pure Python, and in-loop safe.** Rule-level invariants (severity tuning, per-file pairing, `pwn-request`, the tool name) live in `.claude/rules/indicators.md`.
29
+ - **A documented installer is enclosed when the shell lexer OR the line-wide floor says so.** `linewide.py` is the line-wide rule: a line with an odd number of apostrophes or double quotes, or a `<<`, and a word that could run what follows, opens the sixty lines after it. The lexer (`shelllex.py`) reads quoting exactly in the raw text, but cannot see a quote that a YAML scalar, a string literal, a `printf`, a variable or Markdown rendering turns into the one a shell is handed. The union makes the line-wide rule's `critical` a lower bound, except for the removed lines, hunk headers (not their section heading) and `\ No newline` notes of a patch in its rendered view (which is of the new file) and a NUL (the text is read without it, as the shells read it). Never drop or narrow the floor, never relax the lexer's answer on top of it, and test the lexer's relaxations through quote-free openers (`ssh evil.example $(`). See `.claude/rules/indicators.md`.
30
+ - **Models are ROLES configured by env**: `DS_ROOT_LM` planner, `DS_SUB_LM` analyst, `DS_CLASSIFIER_LM` classifier (defaults to the analyst). Refer to them by role; no hardcoded model name.
31
+ - **The budget is hard: `max_retries=1`, no whole-RLM retry inside a run.** One change = one trajectory, so the trace stays valid training data. (`watch` may run a change again, but only when the previous attempt produced no result at all, an infrastructure failure; `cli.run` resets that run id's trace first, so exactly one trajectory per run id survives.) A failed run is infra before it is a schema bug; see `.claude/rules/detect-setup.md` for how the failure names itself.
32
+ - **A second model-judgement is a TOOL, never the sub-LM.** `deep_classify` (on rlm-harness's `make_model_tool`) is the swappable second-stage seam the planner chooses to call, so the decision is a `tool_call` in the trajectory. The analyst intercept (`intercept_sub_lm`) is tracing-only, zero transforms. Swapping the backend touches only `classify_backend` / `_selfclassify_chat`. The planner passes distilled findings, never the whole diff.
33
+ - **Sandboxed code builds no sub-agents.** In dspy 3.4.1 and later, sandboxed code can construct dspy programs that run on the host, call the analyst, can drive the task's tools, and record as one `sub_call` with no inner turns. The kit's default allows it, so `setup` passes `sandbox_sub_agents=False` explicitly, and production never injects an interpreter (which would bypass it); `test_program_offers_no_sub_agents` holds the behaviour.
34
+ - **GitHub ingest, monitoring, and SIEM emission are host-side plumbing, never planner tools.** `ingest.py` shells out to `gh` host-side (transport injectable); `emit.emit_signal` POSTs and never raises; `watch`, `actors` and `snapshot` poll, remember, and archive outside the trajectory. Only choices the policy makes are tools; delivering a finished result is plumbing. What the store learns reaches the signal only as provenance facts through `scan_provenance`, never as a second derivation (see `.claude/rules/monitoring.md`).
35
+ - **This is a ROLLOUT source: trajectories, not reward.** `rl_export` passes `reward=None`; labels read the assembled verdict and metrics are objective effort counters. `deep_classify` tool_calls train the classifier; every other action is the planner's. A metric keeps ONE meaning across rlm-harness versions (the corpus mixes them; `run_start.payload.rlm_harness` names the writer): a quantity a newer kit makes observable gets a NEW key that is None where the trace cannot say (e.g. `analyst_failures`). The ATLAS rubric and the eval member stay reward-free too; see `.claude/rules/rubric-and-eval.md`.
36
+ - **Attack knowledge lives in `diff_sentry/skills/`, not the prompt.** The skill catalog is injected (`load_skills_as_tools(discovery="inject")`) and `read_skill(name)` pulls a body on demand. Fix a wrong convention by editing or adding a skill; `detect.INSTRUCTIONS` holds only identity, the MISSION frame, the tool cost model, the triage loop, and output-gating rules. Skills ship via `packages = ["diff_sentry"]`; a force-include duplicates them and breaks the build.
37
+ - **Keep the dspy-free modules dspy-free.** `config`, `schema`, `normalize`, `indicators`, `shelllex`, `linewide`, `assemble`, `response`, `emit`, `ingest`, `hosts`, `dbutil`, `actors`, `snapshot`, `watch`, `rl_export`, and `rubric` must not import dspy at module top, and `import diff_sentry` must not import dspy (`ClassifyChange` / `setup` / `run` / `screen` / `detect_from_event` are lazy PEP 562 re-exports).
38
+ - **The trace self-describes the run.** `run_start` meta carries the normalized event, instructions, source echo, `baseline_indicators`, `emit_on`, role→model names, budgets, and the rubric skeleton, so an offline re-render/export re-derives the same `signal` (reading `emit_on` from meta, never current config). Any new per-run config that affects read-time derivation must ride in meta.
39
+
40
+ ## Topic guides
41
+
42
+ Claude Code loads each rule automatically when you touch a matching file; other agents should read the file before working in that area.
43
+
44
+ | Area | File |
45
+ |---|---|
46
+ | Indicator rules, severities, corpus | `.claude/rules/indicators.md` |
47
+ | Model roles, subscription auth, run failures | `.claude/rules/detect-setup.md` |
48
+ | ATLAS rubric labels, eval member, studio rubric card | `.claude/rules/rubric-and-eval.md` |
49
+ | Watcher, actor store, snapshot archive, ingest enrichment | `.claude/rules/monitoring.md` |
50
+ | Cutting a release (five version sites) | `.claude/skills/release/SKILL.md` |
51
+ | What to keep across context compaction | `.claude/rules/handoff.md` |
52
+
53
+ ## Layout
54
+
55
+ - rlm-harness owns the generic cores: `make_model_tool` (chat → retry → validate → circuit-break) under `deep_classify`, and the SSRF primitives (`is_safe_url`, `resolved_host_is_safe`, `parse_cidrs`) under `fetch_tool`, which adds the GitHub allowlist, the httpx provider, and tracing.
56
+ - When this consumer needs a workaround, fix the reusable gap in rlm-harness generically; never special-case diff-sentry there. Consumer values (`DS_*` roles, the verdict schema, indicator rules, the SIEM payload shape) stay here.
57
+ - The deterministic tier ships as `cli.screen` and `watch` (host-side, in front of `cli.run`). Next increments: an optional small single-shot model call inside that tier; a GH Archive backfill for account history older than the Events API's 30 days; per-repository workflow awareness (which repos run `issue_comment` or `pull_request_target` workflows) to weight the slash-command rule.
58
+ - Two workspace members, never in the `diff_sentry` wheel: `studio/` (`diff-sentry-studio`, the detection console, behind its `live` extra) and `eval/` (`diff-sentry-eval`, the reward-free scorecard). Both read the trace / `DetectionResponse` contract through the public surface only. Keep the root wheel at `packages = ["diff_sentry"]` so `uv build` never sweeps them in.