diff-sentry 0.4.3__tar.gz → 0.6.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 (146) hide show
  1. diff_sentry-0.6.0/.claude/rules/detect-setup.md +23 -0
  2. diff_sentry-0.6.0/.claude/rules/handoff.md +45 -0
  3. diff_sentry-0.6.0/.claude/rules/indicators.md +281 -0
  4. diff_sentry-0.6.0/.claude/rules/monitoring.md +106 -0
  5. diff_sentry-0.6.0/.claude/rules/rubric-and-eval.md +42 -0
  6. diff_sentry-0.6.0/.claude/skills/release/SKILL.md +37 -0
  7. diff_sentry-0.6.0/.env.example +67 -0
  8. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/.github/workflows/ci.yml +17 -13
  9. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/.github/workflows/release.yml +4 -5
  10. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/.gitignore +1 -1
  11. diff_sentry-0.6.0/AGENTS.md +151 -0
  12. diff_sentry-0.6.0/CHANGELOG.md +233 -0
  13. diff_sentry-0.6.0/PKG-INFO +335 -0
  14. diff_sentry-0.6.0/README.md +314 -0
  15. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/VENDOR.md +5 -6
  16. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/action.yml +4 -4
  17. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/__init__.py +18 -8
  18. diff_sentry-0.6.0/diff_sentry/actors.py +544 -0
  19. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/assemble.py +6 -6
  20. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/cli.py +287 -13
  21. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/config.py +21 -12
  22. diff_sentry-0.6.0/diff_sentry/dbutil.py +42 -0
  23. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/deep_classify.py +7 -8
  24. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/detect.py +5 -3
  25. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/emit.py +21 -6
  26. diff_sentry-0.6.0/diff_sentry/hosts.py +316 -0
  27. diff_sentry-0.6.0/diff_sentry/indicators.py +3381 -0
  28. diff_sentry-0.6.0/diff_sentry/ingest.py +568 -0
  29. diff_sentry-0.6.0/diff_sentry/linewide.py +192 -0
  30. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/normalize.py +94 -11
  31. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/response.py +33 -10
  32. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/rl_export.py +37 -8
  33. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/rubric.py +3 -5
  34. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/schema.py +34 -8
  35. diff_sentry-0.6.0/diff_sentry/shelllex.py +743 -0
  36. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/skills/ci-injection-patterns.md +12 -0
  37. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/skills/triage-a-change.md +10 -0
  38. diff_sentry-0.6.0/diff_sentry/snapshot.py +186 -0
  39. diff_sentry-0.6.0/diff_sentry/watch.py +1025 -0
  40. diff_sentry-0.6.0/eval/README.md +113 -0
  41. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/diff_sentry_eval/cli.py +3 -2
  42. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/diff_sentry_eval/judge.py +1 -1
  43. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/diff_sentry_eval/schema.py +1 -1
  44. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/diff_sentry_eval/score.py +14 -16
  45. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/pyproject.toml +2 -3
  46. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/tests/conftest.py +5 -5
  47. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/tests/test_score.py +4 -4
  48. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/pyproject.toml +17 -20
  49. diff_sentry-0.6.0/studio/DESIGN.md +227 -0
  50. diff_sentry-0.6.0/studio/README.md +209 -0
  51. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/diff_sentry_studio/app.py +34 -13
  52. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/diff_sentry_studio/iterations.py +10 -12
  53. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/diff_sentry_studio/live.py +4 -6
  54. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/diff_sentry_studio/mapper.py +5 -5
  55. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/pyproject.toml +1 -2
  56. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/app.js +6 -3
  57. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/replay-core.js +1 -1
  58. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/run-core.js +14 -4
  59. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/style.css +3 -3
  60. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/replay-core.test.js +3 -3
  61. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/run-core.test.js +18 -0
  62. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/static-contract.test.js +3 -3
  63. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/test_app.py +42 -2
  64. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/test_iterations.py +3 -3
  65. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/test_mapper.py +9 -0
  66. diff_sentry-0.6.0/tests/corpus/hackerbot_claw_campaign.json +165 -0
  67. diff_sentry-0.6.0/tests/oracle/__init__.py +11 -0
  68. diff_sentry-0.6.0/tests/oracle/grammar.py +252 -0
  69. diff_sentry-0.6.0/tests/oracle/harness.py +171 -0
  70. diff_sentry-0.6.0/tests/oracle/lexrunner.py +27 -0
  71. diff_sentry-0.6.0/tests/oracle/programs.py +134 -0
  72. diff_sentry-0.6.0/tests/oracle/runner.py +114 -0
  73. diff_sentry-0.6.0/tests/test_actors.py +742 -0
  74. diff_sentry-0.6.0/tests/test_adversarial_input.py +367 -0
  75. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_assemble.py +2 -2
  76. diff_sentry-0.6.0/tests/test_campaign_replay.py +150 -0
  77. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_cli.py +3 -4
  78. diff_sentry-0.6.0/tests/test_cli_watch.py +443 -0
  79. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_deep_classify.py +3 -3
  80. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_detect.py +1 -2
  81. diff_sentry-0.6.0/tests/test_dogfood_gate.py +59 -0
  82. diff_sentry-0.6.0/tests/test_enclosure.py +944 -0
  83. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_export.py +21 -2
  84. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_forward_offline.py +5 -5
  85. diff_sentry-0.6.0/tests/test_gate_edges.py +373 -0
  86. diff_sentry-0.6.0/tests/test_hosts.py +195 -0
  87. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_incident_hackerbot_claw.py +8 -8
  88. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_indicators.py +19 -9
  89. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_ingest.py +11 -10
  90. diff_sentry-0.6.0/tests/test_ingest_metadata.py +361 -0
  91. diff_sentry-0.6.0/tests/test_linewide.py +1042 -0
  92. diff_sentry-0.6.0/tests/test_metadata_rules.py +5448 -0
  93. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_normalize.py +1 -1
  94. diff_sentry-0.6.0/tests/test_oracle.py +241 -0
  95. diff_sentry-0.6.0/tests/test_oracle_harness.py +45 -0
  96. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_response.py +2 -2
  97. diff_sentry-0.6.0/tests/test_shelllex.py +1514 -0
  98. diff_sentry-0.6.0/tests/test_snapshot.py +178 -0
  99. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_subscription.py +1 -1
  100. diff_sentry-0.6.0/tests/test_watch.py +1340 -0
  101. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/uv.lock +9 -9
  102. diff_sentry-0.4.3/.claude/rules/handoff.md +0 -56
  103. diff_sentry-0.4.3/.env.example +0 -50
  104. diff_sentry-0.4.3/CHANGELOG.md +0 -296
  105. diff_sentry-0.4.3/CLAUDE.md +0 -226
  106. diff_sentry-0.4.3/PKG-INFO +0 -220
  107. diff_sentry-0.4.3/README.md +0 -199
  108. diff_sentry-0.4.3/diff_sentry/indicators.py +0 -723
  109. diff_sentry-0.4.3/diff_sentry/ingest.py +0 -215
  110. diff_sentry-0.4.3/eval/README.md +0 -105
  111. diff_sentry-0.4.3/studio/DESIGN.md +0 -206
  112. diff_sentry-0.4.3/studio/README.md +0 -208
  113. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/.python-version +0 -0
  114. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/LICENSE +0 -0
  115. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/__main__.py +0 -0
  116. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/fetch_tool.py +0 -0
  117. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/skills/prompt-injection-catalog.md +0 -0
  118. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/diff_sentry_eval/__init__.py +0 -0
  119. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/diff_sentry_eval/__main__.py +0 -0
  120. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/diff_sentry_eval/taskset.py +0 -0
  121. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/taskset.example.json +0 -0
  122. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/tests/test_boundary.py +0 -0
  123. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/tests/test_cli.py +0 -0
  124. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/tests/test_judge.py +0 -0
  125. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/tests/test_schema.py +0 -0
  126. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/tests/test_taskset.py +0 -0
  127. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/diff_sentry_studio/__init__.py +0 -0
  128. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/index.html +0 -0
  129. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/trajectory.js +0 -0
  130. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
  131. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
  132. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/test_live.py +0 -0
  133. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/trajectory.test.js +0 -0
  134. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/__init__.py +0 -0
  135. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/conftest.py +0 -0
  136. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/corpus/change_corpus.json +0 -0
  137. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
  138. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/fixtures/known-malicious.diff +0 -0
  139. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_config.py +0 -0
  140. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_detection_quality.py +0 -0
  141. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_emit.py +0 -0
  142. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_fetch_tool.py +0 -0
  143. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_injection_offline.py +0 -0
  144. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_provenance.py +0 -0
  145. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_rubric.py +0 -0
  146. {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_schema.py +0 -0
@@ -0,0 +1,23 @@
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
14
+ (auth / billing / configuration / unsupported model) escapes after one attempt as the raw
15
+ `dspy.LMError` subclass. An `RLMTaskError` means the single attempt ended in an adapter parse failure,
16
+ an invalid result, or a retryable endpoint fault (timeout / 5xx / transport). `cli.run` turns both into
17
+ a `status=failed` response. Check the endpoint before suspecting the schema.
18
+ - **Subscription auth is a lazy, opt-in branch.** A role whose model is `claude-agent-sdk/<id>` runs on
19
+ rlm-harness's `ClaudeAgentLM`, imported only inside `detect._maybe_subscription_lm` and never from
20
+ `__init__.py`, so `import diff_sentry` stays dspy-free and a proxy-only install never touches the SDK.
21
+ - **The classifier never runs on the subscription.** It is a separate OpenAI-compatible client
22
+ (`deep_classify._selfclassify_chat`), so `config.from_env` rejects a `claude-agent-sdk/` classifier
23
+ model, whether set explicitly or inherited from `DS_SUB_LM`. Mixed auth across roles is by design.
@@ -0,0 +1,45 @@
1
+ # Context preservation (read before auto-compacting)
2
+
3
+ Durable knowledge belongs in tracked files; a handoff summary carries only the in-flight session state
4
+ they do not hold yet.
5
+
6
+ - **Stable invariants** → `AGENTS.md` **Hard invariants**, or the topic rule under `.claude/rules/`.
7
+ - **Shipped changes** → the commit message, and a `CHANGELOG.md` entry at release.
8
+ - **Open / proposed work** → the issue tracker, or `AGENTS.md` **Layout** (next increments).
9
+
10
+ A handoff summary covers, in order:
11
+
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.
19
+ 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.
23
+ 6. **The user's in-flight intent and acceptance criteria.**
24
+
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.
28
+
29
+ Format (keep it under ~40 lines):
30
+
31
+ ```
32
+ ## Session state
33
+ - Goal: <one sentence>
34
+ - Status: <what passes, what doesn't, last command + result>
35
+ - Seams: <only the ones touched, one line each>
36
+
37
+ ## Decisions
38
+ - <decision> — <why> (→ AGENTS.md / topic rule / commit message)
39
+
40
+ ## Changed
41
+ - <path:symbol> — <what & why>
42
+
43
+ ## Open
44
+ - [proposed|accepted-not-done|rejected] <item>
45
+ ```
@@ -0,0 +1,281 @@
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
28
+ hangs; a heavier scanner belongs host-side, after the run. `mint_id` is a sha1 over rule and evidence (no time,
29
+ no randomness), so a baseline hit and an in-loop re-scan of the same content dedupe to one union member.
30
+ Evidence is a bounded snippet (`_MAX_EVIDENCE`), never the whole diff.
31
+ - **Severities are tuned, not paranoid.** A plain workflow-file edit is `medium`, below the signal floor on
32
+ purpose: a benign workflow PR must not force a SIEM signal, and a real payload inside the workflow fires the
33
+ high/critical shell and obfuscation rules itself. A CODEOWNERS reassignment is `high`. A bare
34
+ `pull_request_target` (the ordinary label-bot shape) is `medium`; the same trigger checking out the PR head is
35
+ `pwn-request` / `critical`. `dev-tunnel-endpoint`, `content-addressed-host` (IPFS) and `dynamic-code-eval` are
36
+ sub-floor corroborators; pairs with no innocent reading (`child_process` + `detached`, network-fetch +
37
+ file-write) are `high`. Requiring both halves is how a common primitive becomes evidence without becoming noise.
38
+ - **Every rule ships with its negative case**, and `tests/corpus/` pins the suite's hit/miss behavior.
39
+ - **`pwn-request` has three checkout forms, each requiring the privileged trigger:** the head expression on a
40
+ `ref:` (directly or one binding hop away), a hand-rolled `git fetch` / `gh pr checkout`, and
41
+ `allow-unsafe-pr-checkout: true` (the actions/checkout v5.1+ opt-in; the line states the intent, so no dataflow
42
+ is needed). Each form has its own title. A privileged trigger is recognised in the common YAML spellings (map
43
+ key, `on: name`, flow list or map, block-list item, quotes, anchor, trailing comment). `issue_comment` counts
44
+ beside `pull_request_target` and `workflow_run` (it runs with the base repository's secrets), but that trigger
45
+ alone is not a finding; `pull_request_review` and `pull_request_review_comment` are not privileged.
46
+ - **A paired rule only pairs within one file.** `scan_diff` splits a unified diff on its file headers and
47
+ `scan_content` does the same for an event. `normalize.content_segments` is the single definition both
48
+ `raw_content` and the baseline build on. Per-file scoping stops two halves from different files composing into a
49
+ `critical` no file contains, and keeps a hit id byte-stable between a whole-diff and a single-file scan, which is
50
+ what lets the baseline and an in-loop scan dedupe. Non-diff input falls through to a whole-text scan.
51
+ - **The in-loop tool registers as exactly `scan_indicators`.** dspy registers a tool under its `__name__` and the
52
+ prompt calls `scan_indicators(region)`; the inner callable is renamed on purpose (`make_indicator_tool`).
53
+ - **Hits are capped at 25 per (rule, place, title)** (`_MAX_HITS_PER_RULE`), most severe first, so decoys cannot
54
+ evict the critical that follows them. The title is part of the key because `curl-pipe-shell` has three forms. The
55
+ SIEM payload lists the 100 most severe and says how many there were (`indicator_count`).
56
+ - **A detector that raises is a finding, not a silence.** `scan_indicators` runs each detector in `_DETECTORS`
57
+ under a guard; one that raises records a `high` `scanner-error` hit and the rest still run.
58
+ `tests/test_metadata_rules.py` feeds thousands of random hostile strings through the scanners and requires that
59
+ none raises. The prose softening and the rendered view run under the same kind of guard (`_or_closed`): a crash
60
+ keeps the installer as strict as the detector found it and adds a `scanner-error` hit.
61
+ - **Every regex is linear on hostile input.** The watcher runs these rules on any user's text (up to 65,536
62
+ characters in a body) on one poller thread, and a regex that backtracks does not return. Habits: `[ \t]*` after a
63
+ line anchor, never `\s*`; no two adjacent quantifiers that can take the same character; alternatives disjoint
64
+ on their first character; every unbounded `[^\n]*` in front of another pattern gets a `{0,N}` window; host
65
+ patterns anchored and label-bounded. `tests/test_adversarial_input.py` times named vectors and, in a subprocess,
66
+ runs 65,536-character inputs built from the literals of every module-level regex in the package. A regex written
67
+ inside a function is not picked up: compile it at module level.
68
+ - **The change text is scanned once per change in the poller.** `cli._baseline_indicators(event, content_hits=...)`
69
+ and `cli.screen(..., baseline=...)` take the result of the one `scan_change`.
70
+ - **Command words are bounded by ASCII alphanumerics, never `\b` or `\w`**, which know Unicode: `__curl ... | sh__`
71
+ (Markdown emphasis) and `執行curl ...` (CJK prose has no spaces) must still be found.
72
+ - **`invisible-unicode`** covers bidi overrides, embeddings and isolates; the word joiner and invisible math
73
+ operators; the Hangul fillers; a ZWSP anywhere but right after `@` (Dependabot renders `@​user`); a BOM
74
+ anywhere but the start of a line; Unicode tag characters (except the three subdivision flags); the
75
+ variation-selector supplement; and three or more plain variation selectors in a row.
76
+ - **`branch-name-injection` is `high` unless the author is an OWNER, MEMBER or COLLABORATOR** (then `medium`): a
77
+ maintainer can name a branch `fix/handle_${HOME}_expansion`.
78
+
79
+ ## `curl-pipe-shell`
80
+
81
+ A download piped into a shell or an interpreter is `critical`. It has three forms that are judged separately: the
82
+ pipe, the command substitution and PowerShell's. In prose (a `title`, `body` or `comment`, or a documentation file:
83
+ `.md .mdx .markdown .rst .adoc .ipynb .html .htm`) a documented installer is `medium`. Code files (`.sh`, `.go`,
84
+ `.yml`, a Dockerfile, `requirements.txt`) and branches are never softened. The segment kind decides, never a label.
85
+ The planner's `scan_indicators` applies the same softening to the region it is given.
86
+
87
+ ### What it matches
88
+
89
+ - **The pipe form.** The shell is `sh bash zsh dash ksh ash fish csh tcsh` by name or path (quotes or backslashes
90
+ threaded through the letters are fine), `$SHELL`, or one of them behind `env`, `busybox`, `command`, `exec`,
91
+ `nohup`, `nice`, `timeout`, `stdbuf`, `sudo`, `doas`, `pkexec` or `su -c`. An interpreter that reads its program from the pipe
92
+ (`python3 -`, `perl`, `ruby`, `node`, `php`) counts too, but not `python -m`, a script file or `-c` / `-e`. The first
93
+ pipe into a shell is the sink; up to three stages in between; the download word may itself be quoted or split by a
94
+ backslash-newline; up to 300 characters of arguments sit between it and the pipe, after the padding that would
95
+ push a real command out of that window has been folded. The window reads quoted strings (which may run over a line),
96
+ `$'...'` as an ANSI-C string, and a backslash outside a string as bash does (`\'`, `\|` and `\$` are characters of a word,
97
+ `curl -H 'a<newline>b' URL \' | sh`). A second window reads `$` as a plain character (after `\$` or `$$`, and in dash,
98
+ `$'` starts no ANSI-C string), and a third ignores quotes altogether (it finds a Markdown table cell's `\|`). A match of
99
+ any of the three counts.
100
+ - **The command substitution form:** `bash -c "$(curl ...)"`, `bash <(curl ...)`, `source <(curl ...)`,
101
+ `eval "$(curl ...)"`, `python3 <(curl ...)`, here-strings, with the usual prefixes.
102
+ - **The PowerShell form:** `iwr ... | iex` (`curl` and `wget` are aliases of `iwr` there), `iex (irm ...)`,
103
+ `iex ((New-Object Net.WebClient).DownloadString(...))`, `[scriptblock]::Create(...)`.
104
+ - A backslash-continued command and a pipe at the end of a line are joined before matching (`_join_lines`), in the
105
+ scan and in the softening alike. A line that looks like a table row but is not one keeps its lines, and its
106
+ 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
107
+ hits only that view has are added.
108
+
109
+ ### When prose makes it `medium`
110
+
111
+ Every download of the form in the segment must meet all of these; one failure keeps the whole form `critical`.
112
+ The form is part of the hit id, and two hits that share an id keep the more severe one (`keep_worse`).
113
+
114
+ 1. **Known installers only** (`hosts.KNOWN_INSTALLERS`: exact hosts, and exact path patterns for shared hosts such
115
+ as `raw.githubusercontent.com/nvm-sh/nvm/...`). `hosts.installer_only` is strict: every flag is on an allowlist
116
+ (`-sSfL`, `-o-`, `--proto '=https'`, `--retry N`; `--proxy`, `--config`, `--next` and the like fail), every
117
+ argument is a clean URL, and a stand-in (`...`, `<url>`) names no download. `powershell_installer_only` applies the same
118
+ test to PowerShell. Never add a whole platform (github.com, raw.githubusercontent.com) to the list.
119
+ 2. **A plain shell side** (pipe form). A bare shell name or one under `/bin`, `/usr/bin`, `/usr/local/bin`, plain
120
+ flags, no `-c`, no script operand (`sh ./evil.sh`), no `=` or URL; only decode or copy filters between the
121
+ download and the shell (`base64 gunzip gzip zcat xz tee cat`); an interpreter only for a known installer and only
122
+ with the switches it documents.
123
+ 3. **It stands on its own logical line.** Nothing on the line feeds it (`|`, `=`, `&`, `;`, `$`, `xargs`, `-exec`;
124
+ only `NONINTERACTIVE=`, `CI=` and `DEBIAN_FRONTEND=` may precede it), and no program runs it elsewhere or in its
125
+ place (`_wrapped` reads every word in front of the command, after markup, labels, list markers and redirects are
126
+ dropped). The command starts within `_MAX_PROSE_LEAD` characters of its line or inside an inline code span, and
127
+ the rest of the line is prose.
128
+ 4. **Nothing earlier in the segment changes what runs** (`_HIJACK_RE`): a curl config, an alias or function named
129
+ `curl` or a shell, an assignment to `PATH`, `HOME`, `LD_*`, `BASH_ENV`, `SSL_CERT_*`, a URL assigned to any
130
+ variable, a proxy variable, a write to `/etc/hosts`, PowerShell's `Set-Alias`. This is a brake on false positives,
131
+ not a proof: what defeats it is still recorded at `medium` and handed to the planner as evidence.
132
+ 5. **The installer is not enclosed** by anything a wrapper opened above it, and the text has no shape the
133
+ softening does not read (both below).
134
+
135
+ ### Is the installer enclosed?
136
+
137
+ `ssh evil.example 'NEWLINE curl ... | sh NEWLINE '` runs the installer inside ssh's argument, so a documented installer
138
+ stays `critical` when something above it opens a wrapper's quote, here-document or substitution. `enclosed` in
139
+ `_soften_prose` asks two readings of the folded join; **either one** keeps the installer `critical`.
140
+
141
+ - **The line-wide floor (`linewide.py`)** is the line-wide rule that preceded the lexer, kept unchanged. A line
142
+ opens something when its last 300 characters (minus quote, list and prompt markers) hold an odd number of
143
+ apostrophes (one between two letters does not count), an odd number of double quotes or a `<<`, and a word in it
144
+ could run what follows (`linewide.wrapped`: a wrapper, an interpreter, a path, a name glued to the command). The
145
+ 60 lines after an opening line are enclosed. The floor also asks `wrapped` of the text in front of the command on
146
+ its own line. It counts the quotes the text spells, so it sees the ones a YAML scalar, a string literal, `printf`, a
147
+ variable or Markdown rendering turns into the quote a shell is handed, which the lexer cannot. It knows no fence,
148
+ removed line, closed string or syntax error. A text with a NUL is read with and without it.
149
+ - **Guarantee:** what the line-wide rule rates `critical` through its opening-line rule or lead check is never
150
+ softened. Exceptions: the removed lines, hunk headers (not the section heading after the second `@@`, which is a line of the
151
+ new file) and `\ No newline` notes of a patch (the rendered view is of the new file, so a line break it makes
152
+ carries the marker of its line), and a NUL anywhere in the text (it is read without the NUL, as the shells read it).
153
+ - **Do not narrow the floor or relax the lexer's answer on top of it.** Test the lexer's relaxations through
154
+ quote-free openers (`ssh evil.example $(`).
155
+ - **The lexer (`shelllex.py`)** is a one-pass quoting lexer with a stack of open constructs (quotes, `$'...'`,
156
+ backticks, `$(...)`, `<(...)`, `(...)`, `$((...))`, `${...}`) and a queue of pending here-documents, held to
157
+ `bash -n` and `dash -n` by the oracle. What the two shells read differently, or what needs a parser, is `unknown`
158
+ (the docstring lists them) and counts as enclosed on the lines after the first one on which a command may begin like
159
+ a wrapper's. A construct counts only when the command that holds it is a wrapper and does not read as prose
160
+ (`_begins_with_wrapper`, `_wrapped`): `ssh evil.example '`, `docker run x sh -c "`, `ssh evil.example <<'EOF'`. A
161
+ 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
162
+ plain-word host (`ssh host '`) reads as prose.
163
+ - The diff's `+` and a blockquote's `>` are removed, and the text is read in each stretch between code fences. A
164
+ fence is a line of three or more backticks or tildes, at most three blanks in, with nothing in front of it. A
165
+ fence behind a quote or list marker cuts nothing. The lines of an HTML block are text. A string opened in one
166
+ block is not open in the next. CommonMark's container rules are not modelled further.
167
+ - Every reading is also tried from each wrapper word as a fresh start, because prose or a table cell can sit in
168
+ front of a command (`Run:ssh`, `| docker |`). The 60 lines after it are read as a script of its own.
169
+ - `eval` runs the script its first string holds, and a string piped into a shell or an interpreter is a script
170
+ too; both are read as such to a depth of three.
171
+ - 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.
172
+ - The enclosure reads are held to a budget of 12 units per character plus 2,000, and a command of more than 600
173
+ characters is judged from its head and tail. The budget fails closed.
174
+
175
+ ### Shapes the softening does not read, and the rendered view
176
+
177
+ - **The gate (`_unread_shape`)** is fail-closed: a text with a shape that the softening does not read is not softened
178
+ at all, and the extras of its rendered view are not either. The shapes are a lone CR, a diff block in a
179
+ document (a line of the new file that starts with `+` and a non-blank), a notebook array, the strings of a list that
180
+ hold a download and a program, a multi-line array assignment, a YAML single-quoted scalar that goes on, and a
181
+ template placeholder that spells a quote. A crash of the check is a shape.
182
+ - **The rendered view (`_rendered`)** reads a text as a reader copies it: entities decoded, comments and tags
183
+ gone, a block tag a line break, links reduced to their text, emphasis markers removed, a word in backticks reduced to the
184
+ word, a code span of a quote read as that quote. Its stricter hit stands (stricter wins). `_shield_html_fences` blanks
185
+ fence markers inside HTML blocks first. A second view without those last two readings is read as well, because a
186
+ backtick they erase still counts in front of a command (`ssh host <code> ' </code> curl ... | sh`). `.rst`, `.adoc`,
187
+ `.html`, `.htm` and `.ipynb` are not Markdown, so no fence or HTML block cuts there.
188
+
189
+ ### Accepted over-alerts and known gaps
190
+
191
+ The first four bullets are over-alerts, not misses: for the examples they give, the installer stays `critical`.
192
+
193
+ - A letter-apostrophe-letter (`change's`) after a path-like first word makes the rest of that stretch of prose
194
+ enclosed (`/format the change's notes`, `bin/evil doesn't work.`). Markup hides a relative path from the raw
195
+ reading, but an absolute path (`/usr/bin/x`, `./x`, `~/x`) or a typographic quote hides nothing, and the rendered
196
+ view reads a code span or emphasis away, so `` `src/main.py` doesn't read the config. `` before an installer is
197
+ `critical`. A shell would read the apostrophe as an unterminated string.
198
+ - A line led by a wrapper that runs what follows whose comment holds a quote (`uv run pytest # run the project's tests`;
199
+ 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.
200
+ - A table row with a quote, a closer or a stray `$(` or backtick opener followed by a paragraph joins that paragraph; a cell
201
+ after a command written as plain text or `<code>` is a stage of its pipeline; a `+` row inside a fenced `diff` block reads
202
+ as shell text.
203
+ - A tilde fence (the rendered view erases `~~~`), and any text with a gate shape.
204
+ - Known gaps: a hunk or text that starts inside a construct opened above what is visible; indented code read as
205
+ prose; commands that are not known wrappers (`awk`, `parallel`, `aws ssm`, `csh`, `lua -e`);
206
+ a patch whose removed lines or hunk headers sit between `curl U |` and `sh`; single-line sink forms such as
207
+ `| >/dev/null sh`, `| { sh; }`, `| setsid sh`, `| xargs -0 sh -c`; a comment word followed by a backslash; a
208
+ here-document on a multi-line compound command; a redirect after a here-document delimiter; more than about 300
209
+ characters of distinct words between `curl` and the pipe; a quoted-space argument (`' '`) that the padding fold turns into
210
+ a lone quote (`" "` and a string that ends in a blank are folded the same way), together with a quoted argument that runs
211
+ over a line; an argument that is a multi-line `$(...)`, backtick span, `${...}` or `<(...)` in front of the pipe; a quoted
212
+ `|#` 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
213
+ backslash as a shell does and must keep doing so: reading `\'` as a plain character would catch some of these by
214
+ accident, but it closes strings a shell never opens, and the window built that way backtracked exponentially on
215
+ `$'a'` chains and cost hundreds of microseconds per character on `\"` chains.
216
+ - A table cell spells its pipe `\|`, and that is read as a pipe only in a table row that starts with a pipe; elsewhere `\|`
217
+ is an argument of the command (`curl \| URL | sh` is `critical`). So an installer written with `\|` in a table without
218
+ leading pipes, in a paragraph or in an HTML cell is `critical` too, although Markdown renders the `\|` as a pipe there. A
219
+ 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.
220
+ - 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
221
+ (`curl URL<CR>| sh` runs; `\<CR>` is an escaped CR), also for the planner tool and `diff_sentry scan`, which read a
222
+ file or stdin with the CR kept.
223
+
224
+ ### Measured
225
+
226
+ - **Over-alert rate.** The uv installer inserted as its own paragraph into 2,400 real Markdown documents (7,200
227
+ insertions): about 2.5% read `critical` where the line-wide rule alone said `medium`, and none read `medium` where it
228
+ said `critical`. About a quarter of those come from a gate shape (under 1% of documents hold one). Without the
229
+ floor, about 3% of the insertions would have been softened that the line-wide rule kept `critical`. An installer
230
+ whose own line carries a quote (the rustup one) over-alerts more, roughly one insertion in eight on a similar corpus,
231
+ because a plain apostrophe in the prose above pairs with that quote.
232
+ - **Cost is linear.** The worst shape (`curl(` repeated) costs about 10 µs per character per channel (0.7 s for a 65 KB body,
233
+ about 20 s for a 2 MB patch); the hostile enclosure and gate shapes cost 2 to 17 µs per character; plain prose is
234
+ about 2 µs and a README 8 to 14. Tests hold the cost per character flat from 16 KiB to 128 KiB.
235
+ - **The lexer agrees with real shells.** The oracle (`DS_ORACLE=1`) has found no case of the lexer calling a construct closed
236
+ that `bash -n` or `dash -n` reports open since the last fix.
237
+
238
+ ### How it is verified
239
+
240
+ - **The oracle** (`tests/test_oracle.py`, opt-in, Docker): seeded candidates run under the container's real `bash`
241
+ and `dash` with stub `curl`/`wget` and stub programs that only log, no network, no capabilities, a read-only root,
242
+ and a canary download before and after each batch. Four properties: recall in code files, recall in prose
243
+ (whatever a shell really runs stays `critical`), the lexer against `bash -n` / `dash -n`, and soundness of the
244
+ softening (whatever is reduced to `medium` fetches nothing but a known installer and calls nothing else). A new
245
+ spelling goes into `tests/oracle/grammar.py` first. The oracle sees under-detection and unsafe softening, never extra
246
+ alerts, and does not cover PowerShell, zsh, or effects only the real installer would show; guards that only add
247
+ alerts are pinned by unit tests instead.
248
+ - **Unit pins.** `tests/test_linewide.py` pins the floor against its source rule; `tests/test_gate_edges.py` pins the
249
+ gate, rendered view and shield patterns by the shortest texts on which a mutant of each differs;
250
+ `tests/test_enclosure.py`, `tests/test_shelllex.py` and `tests/test_metadata_rules.py` pin the rest end to end.
251
+ A mutant that no text tells from the code is an equivalent one (an alternative another covers, a possessive
252
+ quantifier, a class member under `re.IGNORECASE`, a cost bound).
253
+
254
+ ## Other prose softening
255
+
256
+ All in `indicators._soften_prose`; a code file and a branch are never softened.
257
+
258
+ - **`prompt-injection`** whose only trigger is the bare phrase "system prompt" is `medium`: ordinary in a thread
259
+ about an LLM product. It is `high` when:
260
+ - a real override phrase is present (`ignore previous instructions`, `your system prompt`, `print the system
261
+ prompt`, `new instructions:`, and the previous / prior / earlier / the above family), which must name what is
262
+ dropped (instructions, rules, guidance, context) or ask for an action (approve, merge, lgtm, reply, print,
263
+ reveal, send, exfiltrate, grant, delete, execute). "Ignore all previous commits" and a bare "ignore the above"
264
+ are ordinary sentences;
265
+ - an imperative starts a sentence aimed at the phrase (`Ignore the system prompt ...`, `New system prompt: ...`):
266
+ the verb follows the start of the text or a line, punctuation, a quote, a markdown or diff marker, a table pipe,
267
+ a bullet, a vocative, or `could you` / `can you` / `you need to` / `you will`; never a hyphen, an apostrophe or a
268
+ word;
269
+ - it is addressed to a reviewer or a model: `please`, `you must/should`, or a name (`agents`, `Claude`, `the AI`,
270
+ `reviewers`) with a directive modal, then the verb. A name without a modal describes them. Not through a negation.
271
+
272
+ It stays `medium` when the verb starts a library title with a subordinate clause (`Ignore system prompt when
273
+ counting tokens`), when the phrase starts a longer name (`system prompt caching`) or is a whole table cell, and
274
+ when negated, except that a clause about the review itself (`when reviewing this PR`, `and merge`) makes it an
275
+ instruction. A variable (`SYSTEM_PROMPT`), a flag or a path is not the phrase.
276
+ - **`pwn-request`** is `medium`: a workflow shown in a body, a comment or a documentation file is not one that runs.
277
+ The same text in a workflow file stays `critical`.
278
+ - **`diff-viewport-evasion`** is `medium` in a comment only (a padded markdown table). In a file, including a
279
+ markdown file, it stays `high`.
280
+ - A planner re-scan of text that is not byte-identical to the baseline's window mints another id (a title is scanned
281
+ with the author appended by the baseline), and the stricter baseline hit for a code file always stays in the union.
@@ -0,0 +1,106 @@
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
23
+ trajectory. The planner never queries the store, the queue, or GitHub. The one thing it receives is the
24
+ `actor_history` summary (repositories, outcomes, rule names, hours, and which profile FIELDS changed, never
25
+ the profile text), rendered as an untrusted section because the logins and refs in it came from users.
26
+ - **Cross-event facts are provenance, not a second signal.** `ActorStore.correlate` is read-only and runs
27
+ BEFORE `record`. Its output lands in `event["provenance"]` under plain keys (`actor_*`, `ioc_reuse`,
28
+ `content_revisions`, `evidence_scrubbed`) and `indicators.scan_provenance` turns them into baseline hits.
29
+ Never add a hits or signal path from the store. A change scanned on its own carries none of those keys
30
+ and fires none of those rules.
31
+ - **A deleted account is not an actor.** GitHub's `ghost` is one identity for every deleted user, so `actor_key`
32
+ keys a `ghost` change on the change itself: two unrelated deleted authors never share a history.
33
+ - **Examine oldest first.** Correlation reads what came before a change, so the listing (newest first) is
34
+ sorted ascending before it is examined. A burst examined newest-first would show its last member nothing.
35
+ - **Attribution follows the author of the text, decided structurally.** `IndicatorHit.via` names the commenter
36
+ and is set from the segment KIND in `normalize.content_segments_ex`; it is never parsed out of `location`,
37
+ which is a free-form file path an author can name `comment:alice`. A hit whose `via` is another account is
38
+ recorded against that commenter (`channel='comment'`, only when it reached the floor or fired a slash
39
+ command), and `correlate` reads only the author's own hits, so a drive-by's payload host does not link the
40
+ maintainer's next change and a moderator deleting the comment is not the author scrubbing evidence. A verdict
41
+ on a change whose floor-level evidence is only a third party's goes to that party (`finalize`).
42
+ - **A verdict clears an inference, never evidence.** (`actor-known-bad` is the one model-derived fact that
43
+ enters the baseline: it needs a stored `malicious` verdict, expires after 30 days, and does not apply to
44
+ `Bot` accounts.) A `benign` verdict clears the account-level flag
45
+ (`actor-prior-flag`, an inference about a person), except over a prompt injection, where the verdict may be
46
+ the injection working (`ActorStore._stands`, the only implementation). It never clears `ioc-reuse`, the
47
+ payload-host link: a local model has a real false-benign rate, and letting its verdict erase evidence would
48
+ let one missed payload launder a campaign. `actor-known-bad` needs a `malicious` verdict. The account-level
49
+ rules do not apply to `Bot` accounts; the change-level ones do.
50
+ - **The first copy is kept.** `SnapshotStore.put` runs before anything else touches a change; objects are never
51
+ rewritten and the index is append-only. An edit is a new revision with its own run id (`-r1`), so its trace never
52
+ overwrites the original's, and `prior_indicators` carry the earlier hits forward so a removed payload cannot
53
+ remove the evidence. `evidence-scrubbed` fires when a floor-level hit of an earlier revision is gone (matched by
54
+ rule and location, not id; a payload rewritten to read as prose counts as gone) and names the vanished hit ids,
55
+ so a later edit that still lacks them is the same finding and does not page again. Snapshot the event as
56
+ ingested, before correlation attaches facts, so the archive holds what GitHub said and not what we derived.
57
+ - **Time is the cost, not money.** With a local model every change reaches the model by default; the screen
58
+ sets the order (`_priority`), not eligibility. The caps (`deep_min_severity`, `max_queue`,
59
+ `max_deep_per_hour`) default off. A shed change keeps its `screened/` record and a `shed` state.
60
+ - **The screen is not a trajectory.** `cli.screen` writes `screened/{run_id}.json`, never a trace, so the
61
+ rollout corpus stays trajectories. Its early alert is `stage=screen`; the run's own is `stage=final`.
62
+ - **One model worker.** dspy's global configuration is bound to the thread that first set it up. The poller
63
+ is a second thread and touches no model. Do not add a worker pool without proving `setup` is safe there.
64
+ - **A failed change must not block the ones behind it, and must be retried.** A failure holds the watermark below it
65
+ and withholds the page-1 ETag (otherwise the next listing answers 304 and it is never retried), backs off
66
+ exponentially, and after `_MAX_FAILS` is parked as `error` so it stops holding the listing. The others are skipped
67
+ by their stored `updated_at`. A listing deeper than `_MAX_PAGES` is reported as truncated, never advanced past
68
+ silently. A rate-limit refusal (`429`, or `403` saying so) is not a failure of the change: wait for the reset, and
69
+ `_ingest` subtracts the calls it made from the budget the list responses report.
70
+ - **A read that failed is not a change.** `_comments` returns None on failure and the event carries
71
+ `comments_incomplete`; the watcher then keeps what it has instead of judging the empty thread as an edit.
72
+ Anything that feeds the digest must distinguish "none" from "could not read".
73
+ - **Run ids are unambiguous.** `owner__name-pr-7` (`-r1` per revision). Owners cannot contain `_`, so two
74
+ repositories never collide; the one-off `pr`/`issue` commands keep their older `owner-name-pr-7` scheme, which can.
75
+ - **A failed model run is retried, and pages nothing.** It backs off up to `_MAX_ATTEMPTS` (about four hours, so a
76
+ local model restarting does not fail the queue) with `cli.run` told `emit_failed=False`, then is buried
77
+ (`claimed=2`, event kept) for `--requeue-failed`. Claiming counts as an attempt, so an input that kills the process
78
+ cannot loop across restarts. The queue row stays claimed until the run completes, and `recover()` (called by
79
+ `serve` and `--once`, never by a read-only look) requeues it under a per-directory `flock`. Neither thread may die
80
+ on an unexpected exception.
81
+ - **Undelivered alerts are retried a few at a time.** `_retry_alerts` re-sends at most `_ALERT_RETRIES_PER_POLL`
82
+ (5) per poll, so a SIEM that hangs (a POST is bounded by 15 s) cannot stall polling for a whole backlog.
83
+ - **An alert counts once delivered.** `changes.alerted` gains ids only after the SIEM answered 2xx
84
+ (`EmitResult.ok`); a refusal or outage leaves `alert_pending`, re-sent from `screened/{run_id}.json` on later
85
+ polls. `emitted` alone is also True for a 5xx.
86
+ - **State files are versioned.** `dbutil.open_versioned` stamps `PRAGMA user_version` and refuses a file with
87
+ tables but another stamp; there is no migration, and the error says to move the directory aside.
88
+ - **Account age is measured as of the event.** `_age_days(created_at, as_of)` takes the event's own time;
89
+ the wall clock is only the fallback for a payload with none. A replay of a historical incident must
90
+ carry `created_at`, or every old attacker looks established (`test_campaign_replay` pins this).
91
+ - **Attacker-authored strings stay out of the MF1 sandwich.** Profile fields, branch names, and comments
92
+ are bounded at ingest and rendered in the untrusted section only. Comments are text-scanned only when
93
+ the change's author or an outsider wrote them and the commenter is not a `Bot`: maintainers quote
94
+ install commands and release notes constantly.
95
+ - **Data minimization.** The `email` fields of a profile and of commits are reduced to a domain in the event
96
+ and masked in the archive (`snapshot.scrub_emails`). Free text is archived as captured: it is evidence.
97
+ Nothing prunes the archive, the store, or queued events.
98
+ - **Adding a fact:** capture it in `ingest.py` (best-effort: a failed call omits the key, unless the value feeds
99
+ the digest, in which case mark the event incomplete instead of reporting "none"), put it in
100
+ `provenance` (or a bounded event field), read it in `scan_provenance` / `scan_metadata` with a negative
101
+ case, and keep it sub-floor unless it is standalone evidence. Replay the incident corpus.
102
+ - **Read-only means read-only.** `--stats` and `actor` open the state `mode=ro` (`open_versioned(read_only=True)`)
103
+ and neither create, stamp, nor requeue anything. Only `serve` and `--once` call `recover()`, under the lock.
104
+ - **Terminal output is sanitized.** `format_dossier` replaces control characters; `_json_out` escapes the C1 range
105
+ `json.dumps` leaves raw; URL and token parsing cut at the first control character. A string an attacker wrote
106
+ must not be able to drive the operator's terminal.
@@ -0,0 +1,42 @@
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,
16
+ no-LLM skeleton in `run_start` meta (`rubric_to_meta(default_rubric())`, one criterion per category,
17
+ since the task is constant; there is no `generate_rubric` path) plus deterministic per-criterion
18
+ `criteria_facts`.
19
+ - The facts are a re-lens over `run_labels` / `run_metrics`, not a second derivation: `rubric.trace_facts`
20
+ reuses them, so a criterion's `observed` cannot drift from what a trainer reads. `_CATEGORY_LENS` maps
21
+ TF↔(verdict / signal / hit_iteration_cap), TA↔(scan / deep_classify / analyst / fetch / skill counts +
22
+ circuit-breaks + analyst failures), TG↔(indicator_count / max_indicator_severity / signal /
23
+ cited_unknown), PA↔(verdict / cited_unknown).
24
+ - `CriterionFact` has no score/met field; the trainer scores. Never add a reward/score/met field to any
25
+ rubric type.
26
+ - `rubric.py` is a dspy-free leaf: it imports only `.schema` at top, and its `rl_export` reuse is a
27
+ function-level import (that path is dspy-free too), so `response.py` can import it at top.
28
+ - `response._rubric` surfaces it as `DetectionResponse.rubric` (`RubricReport`); the studio shows it as a
29
+ "labels — not a score" card that adds no judgement. Keep the card legacy-guarded: a response without a
30
+ `rubric` renders nothing.
31
+
32
+ ## The eval member measures; it never rewards
33
+
34
+ - `eval/` (`diff-sentry-eval`) scores a recorded run's assembled verdict with a fixed external ATLAS
35
+ LLM-as-judge: TF/TA/TG/PA on 0–10, TF primary, per-category means only, no composite, no threshold.
36
+ - It is a one-way reader: it reaches diff-sentry only through `verdict_from_events` / `run_labels` /
37
+ `run_metrics` / `AssembledVerdict`, and `diff_sentry` never imports `diff_sentry_eval`
38
+ (`eval/tests/test_boundary.py` enforces it).
39
+ - The judge is rubric-free (it never reads `rubric_signal`, which would bias the measure) and holds
40
+ read-never-execute: it assesses the classification statically and treats the change as untrusted.
41
+ - A run it cannot score (never finalized, no usable verdict, judge failed) is `unscored`, never a fake 0.
42
+ See `eval/README.md`.
@@ -0,0 +1,37 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # Cut a diff-sentry release
7
+
8
+ A release moves FIVE version sites together, plus a `CHANGELOG.md` entry:
9
+
10
+ 1. `pyproject.toml` `[project].version`
11
+ 2. `diff_sentry/__init__.py` `__version__`
12
+ 3. `action.yml` `inputs.version.default` (decides which PyPI release a consumer's `uses: …@vX.Y.Z` installs)
13
+ 4. `README.md` quickstart `uses: qazbnm456/diff-sentry@vX.Y.Z`
14
+ 5. `uv.lock`, via `uv lock`
15
+
16
+ `release.yml` fails the build unless the tag, the built wheel's version, the installed `__version__`, and
17
+ the `action.yml` default all agree, and it checks that the installed console script flags a known-malicious
18
+ diff. Nothing gates the README `uses:` line: a stale one sends new adopters to the previous release, so
19
+ check it by hand.
20
+
21
+ ## Steps
22
+
23
+ 1. Freeze the boundary: `git log v<last>..HEAD`. Every `feat:` / `fix:` / `deps:` commit inside it is a
24
+ CHANGELOG candidate.
25
+ 2. Write the `CHANGELOG.md` entry in the file's existing style: one sentence per user-visible outcome,
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
28
+ (`uv run --package diff-sentry-eval --extra dev python -m pytest eval/tests`), `uvx ruff check .`.
29
+ 4. Commit (`chore: release X.Y.Z`) and push to `main` once the user approves. CI's `consumer install
30
+ path (PyPI)` job then goes RED on this commit, by design: it installs `action.yml`'s default version
31
+ from PyPI, which does not exist until step 5. Every other job must be green before you continue.
32
+ 5. Create a DRAFT release (`gh release create vX.Y.Z --draft --target <release sha> --title … --notes-file
33
+ …`, notes = the CHANGELOG section). The user publishes it in the web UI with "Publish this Action to
34
+ the GitHub Marketplace" ticked (the CLI cannot tick it). Publishing is what builds, verifies, and
35
+ uploads to PyPI via Trusted Publishing, and the upload is irreversible.
36
+ 6. After `release.yml` succeeds, re-run the failed consumer-install job
37
+ (`gh run rerun <ci run id> --failed`); green proves the published package installs and scans.
@@ -0,0 +1,67 @@
1
+ # diff-sentry: model roles and runtime knobs. Copy to .env; a live run needs these plus a Deno sandbox.
2
+
3
+ # --- Model roles (required for a live `pr`/`issue`/`classify` run) ---
4
+ # planner: a cheap, injection-resistant orchestrator that holds the diff in the REPL and triages.
5
+ DS_ROOT_LM=your-planner-model
6
+ # analyst: an expensive model reached via llm_query for a subtle source->sink (short questions only).
7
+ DS_SUB_LM=your-analyst-model
8
+ # One OpenAI-compatible proxy serves all roles. When DS_BASE_URL is set the model names are the plain
9
+ # ids your endpoint serves (e.g. qwen/qwen3-next), not litellm-prefixed.
10
+ DS_BASE_URL=https://your-openai-compatible-endpoint/v1
11
+ DS_API_KEY=sk-...
12
+
13
+ # --- OR: run planner + analyst on a Claude Pro/Max subscription (no API key) ---
14
+ # A role with the `claude-agent-sdk/<id>` prefix runs on your Claude subscription through the Claude
15
+ # Agent SDK (`rlm_harness.ClaudeAgentLM`, the `subscription` extra). Each call is a pure completion:
16
+ # no tools, no filesystem, no settings leakage. Prerequisites:
17
+ # - `uv sync --extra subscription`
18
+ # - the Claude Code CLI installed and logged in (`claude` then /login, or `claude setup-token` and
19
+ # export CLAUDE_CODE_OAUTH_TOKEN)
20
+ # - `unset ANTHROPIC_API_KEY`: the CLI prefers it over the subscription and would bill API credit,
21
+ # so the adapter refuses to start while it is set
22
+ # Prefer full model ids over aliases, which drift.
23
+ # DS_ROOT_LM=claude-agent-sdk/claude-sonnet-5 # planner on Sonnet 5
24
+ # DS_SUB_LM=claude-agent-sdk/claude-fable-5 # analyst on Fable 5
25
+ # DS_BASE_URL/DS_API_KEY then serve only the classifier, which always needs an OpenAI-compatible
26
+ # endpoint (a subscription-only run is not supported). Set DS_CLASSIFIER_LM to a real model id (plus
27
+ # DS_CLASSIFIER_BASE_URL/DS_CLASSIFIER_API_KEY if it is a separate box). Left unset, it inherits the
28
+ # `claude-agent-sdk/…` DS_SUB_LM and config loading fails with an actionable error.
29
+ # Budgets use the same env vars as proxy mode. Each call adds ~2-5s of CLI-spawn overhead, so keep
30
+ # these runs interactive, not batch. DS_PLANNER_MAX_TOKENS has no effect here: the Agent SDK exposes
31
+ # no token or sampling controls.
32
+
33
+ # --- Second-stage classifier (the swappable seam) ---
34
+ # Defaults to the analyst model on DS_BASE_URL/DS_API_KEY. Required when the analyst runs on the
35
+ # subscription (see above).
36
+ # DS_CLASSIFIER_LM=your-strong-classifier
37
+ # DS_CLASSIFIER_BASE_URL=https://your-classifier-endpoint/v1
38
+ # DS_CLASSIFIER_API_KEY=sk-...
39
+ # DS_CLASSIFIER_CIRCUIT_BREAK=4
40
+
41
+ # --- RLM runtime ---
42
+ # DS_INTERPRETER=pyodide # read-not-execute: the sandbox never runs the change. `deno` is an alias.
43
+ # DS_MAX_ITERATIONS=25
44
+ # DS_MAX_LLM_CALLS=8 # caps only analyst (llm_query) escalations
45
+ # DS_ADAPTER=json # portable structured output (OpenAI + vLLM/NIM)
46
+ # DS_PLANNER_MAX_TOKENS=16384 # generous, so a reasoning planner isn't truncated to empty content
47
+
48
+ # --- Enrichment fetch (off by default: an exfil surface on attacker-authored input; GitHub-allowlisted) ---
49
+ # DS_ENABLE_FETCH=false
50
+ # DS_FETCH_ALLOW_CIDRS=198.18.0.0/16 # only if behind a fake-IP proxy / split-DNS VPN
51
+
52
+ # --- Host-side SIEM emitter (opt-in; deterministic POST after the run) ---
53
+ # DS_SIEM_WEBHOOK_URL=https://your-siem/intake
54
+ # DS_SIEM_TOKEN=...
55
+ # DS_EMIT_ON=suspicious,malicious
56
+
57
+ # --- Watching a watchlist (`diff-sentry watch`) ---
58
+ # Time is the only cost with a local model, so the caps are OFF unless set.
59
+ # DS_WATCH_REPOS=owner/name,other/name # or --repos / --repos-file
60
+ # DS_WATCH_STATE_DIR=./output/state # watch.db, actors.db, snapshots/
61
+ # DS_WATCH_INTERVAL=60 # seconds between polls (raised if GitHub sends X-Poll-Interval)
62
+ # DS_WATCH_BACKFILL_HOURS=0 # on first sight of a repo, also examine changes this recent
63
+ # DS_WATCH_RECHECK_HOURS=24 # shorten the re-check schedule (15 min, 1 h, 6 h, 24 h); it cannot extend it
64
+ # DS_WATCH_DEEP_MIN_SEVERITY=info # info = every change goes to the model; raise it to gate on the screen
65
+ # DS_WATCH_MAX_QUEUE=0 # 0 = unbounded; otherwise the lowest priority is shed and recorded
66
+ # DS_WATCH_MAX_DEEP_PER_HOUR=0 # 0 = unlimited model runs per hour
67
+ # DS_WATCH_RATE_RESERVE=200 # pause polling when the API's remaining budget falls below this