diff-sentry 0.5.0__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 (138) hide show
  1. diff_sentry-0.6.0/.claude/rules/indicators.md +281 -0
  2. diff_sentry-0.6.0/.claude/rules/monitoring.md +106 -0
  3. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.claude/skills/release/SKILL.md +9 -4
  4. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.env.example +12 -0
  5. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/AGENTS.md +44 -19
  6. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/CHANGELOG.md +62 -0
  7. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/PKG-INFO +121 -6
  8. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/README.md +120 -5
  9. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/action.yml +1 -1
  10. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/__init__.py +16 -6
  11. diff_sentry-0.6.0/diff_sentry/actors.py +544 -0
  12. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/assemble.py +2 -3
  13. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/cli.py +287 -13
  14. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/config.py +12 -0
  15. diff_sentry-0.6.0/diff_sentry/dbutil.py +42 -0
  16. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/detect.py +3 -1
  17. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/emit.py +21 -6
  18. diff_sentry-0.6.0/diff_sentry/hosts.py +316 -0
  19. diff_sentry-0.6.0/diff_sentry/indicators.py +3381 -0
  20. diff_sentry-0.6.0/diff_sentry/ingest.py +568 -0
  21. diff_sentry-0.6.0/diff_sentry/linewide.py +192 -0
  22. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/normalize.py +90 -6
  23. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/response.py +25 -5
  24. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/schema.py +26 -2
  25. diff_sentry-0.6.0/diff_sentry/shelllex.py +743 -0
  26. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/skills/ci-injection-patterns.md +12 -0
  27. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/skills/triage-a-change.md +10 -0
  28. diff_sentry-0.6.0/diff_sentry/snapshot.py +186 -0
  29. diff_sentry-0.6.0/diff_sentry/watch.py +1025 -0
  30. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/pyproject.toml +1 -1
  31. diff_sentry-0.6.0/tests/corpus/hackerbot_claw_campaign.json +165 -0
  32. diff_sentry-0.6.0/tests/oracle/__init__.py +11 -0
  33. diff_sentry-0.6.0/tests/oracle/grammar.py +252 -0
  34. diff_sentry-0.6.0/tests/oracle/harness.py +171 -0
  35. diff_sentry-0.6.0/tests/oracle/lexrunner.py +27 -0
  36. diff_sentry-0.6.0/tests/oracle/programs.py +134 -0
  37. diff_sentry-0.6.0/tests/oracle/runner.py +114 -0
  38. diff_sentry-0.6.0/tests/test_actors.py +742 -0
  39. diff_sentry-0.6.0/tests/test_adversarial_input.py +367 -0
  40. diff_sentry-0.6.0/tests/test_campaign_replay.py +150 -0
  41. diff_sentry-0.6.0/tests/test_cli_watch.py +443 -0
  42. diff_sentry-0.6.0/tests/test_dogfood_gate.py +59 -0
  43. diff_sentry-0.6.0/tests/test_enclosure.py +944 -0
  44. diff_sentry-0.6.0/tests/test_gate_edges.py +373 -0
  45. diff_sentry-0.6.0/tests/test_hosts.py +195 -0
  46. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_indicators.py +10 -0
  47. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_ingest.py +9 -8
  48. diff_sentry-0.6.0/tests/test_ingest_metadata.py +361 -0
  49. diff_sentry-0.6.0/tests/test_linewide.py +1042 -0
  50. diff_sentry-0.6.0/tests/test_metadata_rules.py +5448 -0
  51. diff_sentry-0.6.0/tests/test_oracle.py +241 -0
  52. diff_sentry-0.6.0/tests/test_oracle_harness.py +45 -0
  53. diff_sentry-0.6.0/tests/test_shelllex.py +1514 -0
  54. diff_sentry-0.6.0/tests/test_snapshot.py +178 -0
  55. diff_sentry-0.6.0/tests/test_watch.py +1340 -0
  56. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/uv.lock +1 -1
  57. diff_sentry-0.5.0/.claude/rules/indicators.md +0 -42
  58. diff_sentry-0.5.0/diff_sentry/indicators.py +0 -712
  59. diff_sentry-0.5.0/diff_sentry/ingest.py +0 -213
  60. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.claude/rules/detect-setup.md +0 -0
  61. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.claude/rules/handoff.md +0 -0
  62. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.claude/rules/rubric-and-eval.md +0 -0
  63. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.github/workflows/ci.yml +0 -0
  64. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.github/workflows/release.yml +0 -0
  65. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.gitignore +0 -0
  66. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.python-version +0 -0
  67. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/LICENSE +0 -0
  68. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/VENDOR.md +0 -0
  69. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/__main__.py +0 -0
  70. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/deep_classify.py +0 -0
  71. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/fetch_tool.py +0 -0
  72. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/rl_export.py +0 -0
  73. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/rubric.py +0 -0
  74. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/skills/prompt-injection-catalog.md +0 -0
  75. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/README.md +0 -0
  76. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/diff_sentry_eval/__init__.py +0 -0
  77. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/diff_sentry_eval/__main__.py +0 -0
  78. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/diff_sentry_eval/cli.py +0 -0
  79. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/diff_sentry_eval/judge.py +0 -0
  80. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/diff_sentry_eval/schema.py +0 -0
  81. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/diff_sentry_eval/score.py +0 -0
  82. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/diff_sentry_eval/taskset.py +0 -0
  83. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/pyproject.toml +0 -0
  84. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/taskset.example.json +0 -0
  85. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/tests/conftest.py +0 -0
  86. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/tests/test_boundary.py +0 -0
  87. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/tests/test_cli.py +0 -0
  88. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/tests/test_judge.py +0 -0
  89. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/tests/test_schema.py +0 -0
  90. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/tests/test_score.py +0 -0
  91. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/tests/test_taskset.py +0 -0
  92. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/DESIGN.md +0 -0
  93. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/README.md +0 -0
  94. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/diff_sentry_studio/__init__.py +0 -0
  95. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/diff_sentry_studio/app.py +0 -0
  96. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/diff_sentry_studio/iterations.py +0 -0
  97. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/diff_sentry_studio/live.py +0 -0
  98. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/diff_sentry_studio/mapper.py +0 -0
  99. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/pyproject.toml +0 -0
  100. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/app.js +0 -0
  101. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/index.html +0 -0
  102. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/replay-core.js +0 -0
  103. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/run-core.js +0 -0
  104. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/style.css +0 -0
  105. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/trajectory.js +0 -0
  106. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
  107. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
  108. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/replay-core.test.js +0 -0
  109. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/run-core.test.js +0 -0
  110. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/static-contract.test.js +0 -0
  111. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/test_app.py +0 -0
  112. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/test_iterations.py +0 -0
  113. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/test_live.py +0 -0
  114. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/test_mapper.py +0 -0
  115. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/trajectory.test.js +0 -0
  116. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/__init__.py +0 -0
  117. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/conftest.py +0 -0
  118. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/corpus/change_corpus.json +0 -0
  119. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
  120. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/fixtures/known-malicious.diff +0 -0
  121. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_assemble.py +0 -0
  122. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_cli.py +0 -0
  123. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_config.py +0 -0
  124. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_deep_classify.py +0 -0
  125. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_detect.py +0 -0
  126. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_detection_quality.py +0 -0
  127. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_emit.py +0 -0
  128. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_export.py +0 -0
  129. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_fetch_tool.py +0 -0
  130. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_forward_offline.py +0 -0
  131. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_incident_hackerbot_claw.py +0 -0
  132. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_injection_offline.py +0 -0
  133. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_normalize.py +0 -0
  134. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_provenance.py +0 -0
  135. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_response.py +0 -0
  136. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_rubric.py +0 -0
  137. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_schema.py +0 -0
  138. {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_subscription.py +0 -0
@@ -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.
@@ -26,7 +26,12 @@ check it by hand.
26
26
  breaking changes and required actions explicit, no debugging story.
27
27
  3. Bump sites 1–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
- 4. Commit (`chore: release X.Y.Z`) and push to `main` once the user approves.
30
- 5. Publishing a GitHub Release tagged `vX.Y.Z` (with "Publish this Action to the GitHub Marketplace"
31
- ticked) is what builds, verifies, and uploads to PyPI via Trusted Publishing. The upload is
32
- irreversible; confirm with the user before publishing.
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.
@@ -53,3 +53,15 @@ DS_API_KEY=sk-...
53
53
  # DS_SIEM_WEBHOOK_URL=https://your-siem/intake
54
54
  # DS_SIEM_TOKEN=...
55
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
@@ -1,4 +1,4 @@
1
- # diff-sentry — agent guide
1
+ # diff-sentry: agent guide
2
2
 
3
3
  A BewAIre-style malicious-change detector built on `rlm-harness`. It classifies ONE GitHub change
4
4
  (PR / issue / push) for malicious intent: the change is UNTRUSTED DATA held in a sandboxed REPL, the
@@ -11,18 +11,24 @@ rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, lock
11
11
 
12
12
  ## Verify
13
13
 
14
- - `uv run pytest` — the full offline suite (`uv sync --group dev` first). No live LLM, network, or Deno:
14
+ - `uv run pytest`: the full offline suite (`uv sync --group dev` first). No live LLM, network, or Deno:
15
15
  dspy paths use DummyLM / rlm-harness's `ScriptedInterpreter`, transports are injected fakes, and
16
16
  `tests/corpus/` pins the indicator suite's hit/miss behavior.
17
- - `uv run --package diff-sentry-eval --extra dev python -m pytest eval/tests` — the eval member's own suite
17
+ - `uv run --package diff-sentry-eval --extra dev python -m pytest eval/tests`: the eval member's own suite
18
18
  (the `--package` is required; a root `uv run` does not install the member). Run it when you touch
19
19
  `assemble.py`, `rl_export.py`, `rubric.py`, `schema.py`, or the trace payloads it scores.
20
- - `uvx ruff check .` — lint (line-length 110). CI runs it as its own job, next to both suites and the
20
+ - `DS_ORACLE=1 uv run pytest tests/test_oracle.py`: the shell oracle (opt-in; needs Docker and the `python:3.11-slim`
21
+ image): candidate download-and-execute texts are run under a real `bash` and `dash` in a no-network, cap-dropped
22
+ container with a stub `curl`, and the rules are held to what actually ran (`DS_ORACLE_N` / `DS_ORACLE_SEED` scale
23
+ it). Run it when you touch the `curl-pipe-shell` patterns, the prose softening in `indicators.py` (`_soften_prose`
24
+ and the helpers it calls, the shape gate, the rendered view), `hosts.py`, `linewide.py`, or `shelllex.py` (the
25
+ lexer behind the enclosure check, held to what `bash -n` and `dash -n` say about generated fragments).
26
+ - `uvx ruff check .`: lint (line-length 110). CI runs it as its own job, next to both suites and the
21
27
  node tests (`.github/workflows/ci.yml`).
22
28
  - A live run needs role creds (`DS_*`, see `.env.example`), Deno (`brew install deno`), and `gh` for
23
29
  `pr`/`issue` ingest. `render`/`export` are offline; `classify` ingests offline but classifies live.
24
30
 
25
- ## Running — always through the CLI
31
+ ## Running: always through the CLI
26
32
 
27
33
  - Run via `cli` (`pr` / `issue` / `classify`), never an ad-hoc script. `cli.run(event, …)` is the
28
34
  programmatic entry: it records `<out>/traces/{run_id}.jsonl` (dropping a stale one first, since
@@ -30,9 +36,12 @@ rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, lock
30
36
  after the run. It never raises: a crash still writes a `status=failed` response that can signal off the
31
37
  evidence floor. Extend `cli.py` rather than driving `detect_from_event` / `build_response` directly.
32
38
  - Offline: `python -m diff_sentry render <trace> <run_id>` re-renders a response;
33
- `python -m diff_sentry export "output/traces/*.jsonl" ds.json` exports the reward-free dataset.
39
+ `python -m diff_sentry export "output/traces/*.jsonl" ds.json` exports the reward-free dataset;
40
+ `actor <login>` prints an account's dossier and `archive verify` re-hashes the snapshot archive.
41
+ - Continuous collection goes through `watch`, which calls `cli.run` (model) or `cli.screen`
42
+ (deterministic, no creds) and never `detect_from_event` directly. `--screen-only` needs no model roles.
34
43
 
35
- ## Hard invariants — do not break
44
+ ## Hard invariants: do not break
36
45
 
37
46
  - **The change is DATA: read, never execute.** It is a REPL variable under the default `pyodide`
38
47
  interpreter; nothing in it is run, built, or fetched-and-run. An embedded instruction is a
@@ -63,20 +72,33 @@ rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, lock
63
72
  localhost/metadata.
64
73
  - **Indicators are deterministic, pure Python, and in-loop safe.** Rule-level invariants (severity
65
74
  tuning, per-file pairing, `pwn-request`, the tool name) live in `.claude/rules/indicators.md`.
75
+ - **A documented installer is enclosed when the shell lexer OR the line-wide floor says so.** `linewide.py` is
76
+ the line-wide rule that preceded the lexer (a line with an odd number of apostrophes or double quotes, or a `<<`,
77
+ and a word that could run what follows, opens the sixty lines after it), kept unchanged. The lexer
78
+ (`shelllex.py`) reads quoting exactly in the raw text, but cannot see a quote that a YAML scalar, a string literal,
79
+ a `printf`, a variable or Markdown rendering turns into the one a shell is handed. The union makes the line-wide rule's
80
+ `critical` a lower bound, except for the removed lines, hunk headers (not their section heading) and `\ No newline` notes of a patch in
81
+ 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
82
+ the lexer's answer on top of it, and test the lexer's relaxations through quote-free openers
83
+ (`ssh evil.example $(`). See `.claude/rules/indicators.md`.
66
84
  - **Models are ROLES configured by env**: `DS_ROOT_LM` planner, `DS_SUB_LM` analyst, `DS_CLASSIFIER_LM`
67
85
  classifier (defaults to the analyst). Refer to them by role; no hardcoded model name.
68
- - **The budget is hard: `max_retries=1`, no whole-RLM retry.** One change = one trajectory, so the trace
69
- stays valid training data. A failed run is infra before it is a schema bug; see
86
+ - **The budget is hard: `max_retries=1`, no whole-RLM retry inside a run.** One change = one trajectory, so the
87
+ trace stays valid training data. (`watch` may run a change again, but only when the previous attempt produced
88
+ no result at all, an infrastructure failure; `cli.run` resets that run id's trace first, so exactly one
89
+ trajectory per run id survives.) A failed run is infra before it is a schema bug; see
70
90
  `.claude/rules/detect-setup.md` for how the failure names itself.
71
91
  - **A second model-judgement is a TOOL, never the sub-LM.** `deep_classify` (on rlm-harness's
72
92
  `make_model_tool`) is the swappable second-stage seam the planner chooses to call, so the decision is a
73
93
  `tool_call` in the trajectory. The analyst intercept (`intercept_sub_lm`) is tracing-only, zero
74
94
  transforms. Swapping the backend touches only `classify_backend` / `_selfclassify_chat`. The planner
75
95
  passes distilled findings, never the whole diff.
76
- - **GitHub ingest and SIEM emission are host-side plumbing, never planner tools.** `ingest.py` shells out
77
- to `gh` host-side (transport injectable); `emit.emit_signal` POSTs after the run, stays out of the
78
- trajectory, and never raises. Only choices the policy makes are tools; delivering a finished result is
79
- plumbing.
96
+ - **GitHub ingest, monitoring, and SIEM emission are host-side plumbing, never planner tools.** `ingest.py`
97
+ shells out to `gh` host-side (transport injectable); `emit.emit_signal` POSTs and never raises; `watch`,
98
+ `actors` and `snapshot` poll, remember, and archive outside the trajectory. Only choices the policy makes
99
+ are tools; delivering a finished result is plumbing. What the store learns reaches the signal only as
100
+ provenance facts through `scan_provenance`, never as a second derivation (see
101
+ `.claude/rules/monitoring.md`).
80
102
  - **This is a ROLLOUT source: trajectories, not reward.** `rl_export` passes `reward=None`; labels read
81
103
  the assembled verdict and metrics are objective effort counters. `deep_classify` tool_calls train the
82
104
  classifier; every other action is the planner's. A metric keeps ONE meaning across rlm-harness
@@ -88,10 +110,10 @@ rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, lock
88
110
  convention by editing or adding a skill; `detect.INSTRUCTIONS` holds only identity, the MISSION frame,
89
111
  the tool cost model, the triage loop, and output-gating rules. Skills ship via
90
112
  `packages = ["diff_sentry"]`; a force-include duplicates them and breaks the build.
91
- - **Keep the dspy-free modules dspy-free.** `config`, `schema`, `normalize`, `indicators`, `assemble`,
92
- `response`, `emit`, `ingest`, `rl_export`, and `rubric` must not import dspy at module top, and
93
- `import diff_sentry` must not import dspy (`ClassifyChange` / `setup` / `run` / `detect_from_event` are
94
- lazy PEP 562 re-exports).
113
+ - **Keep the dspy-free modules dspy-free.** `config`, `schema`, `normalize`, `indicators`, `shelllex`, `linewide`, `assemble`,
114
+ `response`, `emit`, `ingest`, `hosts`, `dbutil`, `actors`, `snapshot`, `watch`, `rl_export`, and `rubric`
115
+ must not import dspy at module top, and `import diff_sentry` must not import dspy (`ClassifyChange` / `setup` /
116
+ `run` / `screen` / `detect_from_event` are lazy PEP 562 re-exports).
95
117
  - **The trace self-describes the run.** `run_start` meta carries the normalized event, instructions,
96
118
  source echo, `baseline_indicators`, `emit_on`, role→model names, budgets, and the rubric skeleton, so
97
119
  an offline re-render/export re-derives the same `signal` (reading `emit_on` from meta, never current
@@ -107,6 +129,7 @@ file before working in that area.
107
129
  | Indicator rules, severities, corpus | `.claude/rules/indicators.md` |
108
130
  | Model roles, subscription auth, run failures | `.claude/rules/detect-setup.md` |
109
131
  | ATLAS rubric labels, eval member, studio rubric card | `.claude/rules/rubric-and-eval.md` |
132
+ | Watcher, actor store, snapshot archive, ingest enrichment | `.claude/rules/monitoring.md` |
110
133
  | Cutting a release (five version sites) | `.claude/skills/release/SKILL.md` |
111
134
  | What to keep across context compaction | `.claude/rules/handoff.md` |
112
135
 
@@ -118,8 +141,10 @@ file before working in that area.
118
141
  - When this consumer needs a workaround, fix the reusable gap in rlm-harness generically; never
119
142
  special-case diff-sentry there. Consumer values (`DS_*` roles, the verdict schema, indicator rules, the
120
143
  SIEM payload shape) stay here.
121
- - Next increments: a cheap pre-filter tier (deterministic indicators + one small single-shot call) as
122
- host-side plumbing in front of `cli.run`, not extra RLM turns; and live GitHub/SIEM wiring.
144
+ - The deterministic tier ships as `cli.screen` and `watch` (host-side, in front of `cli.run`). Next
145
+ increments: an optional small single-shot model call inside that tier; a GH Archive backfill for account
146
+ history older than the Events API's 30 days; per-repository workflow awareness (which repos run
147
+ `issue_comment` or `pull_request_target` workflows) to weight the slash-command rule.
123
148
  - Two workspace members, never in the `diff_sentry` wheel: `studio/` (`diff-sentry-studio`, the detection
124
149
  console, behind its `live` extra) and `eval/` (`diff-sentry-eval`, the reward-free scorecard). Both read
125
150
  the trace / `DetectionResponse` contract through the public surface only. Keep the root wheel at