diff-sentry 0.4.1__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/.github/workflows/ci.yml +7 -7
  2. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/.github/workflows/release.yml +2 -2
  3. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/CHANGELOG.md +104 -0
  4. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/CLAUDE.md +29 -4
  5. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/PKG-INFO +4 -4
  6. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/README.md +2 -2
  7. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/action.yml +2 -2
  8. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/__init__.py +4 -2
  9. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/cli.py +4 -5
  10. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/config.py +2 -1
  11. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/indicators.py +102 -9
  12. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/normalize.py +17 -5
  13. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/pyproject.toml +3 -3
  14. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/diff_sentry_studio/iterations.py +12 -3
  15. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/diff_sentry_studio/live.py +3 -1
  16. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/test_iterations.py +12 -0
  17. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/corpus/change_corpus.json +28 -0
  18. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_detection_quality.py +5 -4
  19. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_indicators.py +120 -1
  20. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/uv.lock +11 -317
  21. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/.claude/rules/handoff.md +0 -0
  22. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/.env.example +0 -0
  23. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/.gitignore +0 -0
  24. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/.python-version +0 -0
  25. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/LICENSE +0 -0
  26. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/VENDOR.md +0 -0
  27. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/__main__.py +0 -0
  28. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/assemble.py +0 -0
  29. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/deep_classify.py +0 -0
  30. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/detect.py +0 -0
  31. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/emit.py +0 -0
  32. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/fetch_tool.py +0 -0
  33. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/ingest.py +0 -0
  34. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/response.py +0 -0
  35. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/rl_export.py +0 -0
  36. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/rubric.py +0 -0
  37. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/schema.py +0 -0
  38. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/skills/ci-injection-patterns.md +0 -0
  39. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/skills/prompt-injection-catalog.md +0 -0
  40. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/skills/triage-a-change.md +0 -0
  41. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/README.md +0 -0
  42. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/diff_sentry_eval/__init__.py +0 -0
  43. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/diff_sentry_eval/__main__.py +0 -0
  44. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/diff_sentry_eval/cli.py +0 -0
  45. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/diff_sentry_eval/judge.py +0 -0
  46. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/diff_sentry_eval/schema.py +0 -0
  47. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/diff_sentry_eval/score.py +0 -0
  48. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/diff_sentry_eval/taskset.py +0 -0
  49. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/pyproject.toml +0 -0
  50. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/taskset.example.json +0 -0
  51. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/tests/conftest.py +0 -0
  52. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/tests/test_boundary.py +0 -0
  53. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/tests/test_cli.py +0 -0
  54. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/tests/test_judge.py +0 -0
  55. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/tests/test_schema.py +0 -0
  56. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/tests/test_score.py +0 -0
  57. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/tests/test_taskset.py +0 -0
  58. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/DESIGN.md +0 -0
  59. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/README.md +0 -0
  60. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/diff_sentry_studio/__init__.py +0 -0
  61. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/diff_sentry_studio/app.py +0 -0
  62. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/diff_sentry_studio/mapper.py +0 -0
  63. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/pyproject.toml +0 -0
  64. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/app.js +0 -0
  65. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/index.html +0 -0
  66. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/replay-core.js +0 -0
  67. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/run-core.js +0 -0
  68. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/style.css +0 -0
  69. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/trajectory.js +0 -0
  70. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
  71. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
  72. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/replay-core.test.js +0 -0
  73. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/run-core.test.js +0 -0
  74. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/static-contract.test.js +0 -0
  75. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/test_app.py +0 -0
  76. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/test_live.py +0 -0
  77. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/test_mapper.py +0 -0
  78. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/trajectory.test.js +0 -0
  79. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/__init__.py +0 -0
  80. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/conftest.py +0 -0
  81. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/corpus/hackerbot_claw_incident.json +0 -0
  82. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/fixtures/known-malicious.diff +0 -0
  83. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_assemble.py +0 -0
  84. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_cli.py +0 -0
  85. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_config.py +0 -0
  86. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_deep_classify.py +0 -0
  87. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_detect.py +0 -0
  88. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_emit.py +0 -0
  89. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_export.py +0 -0
  90. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_fetch_tool.py +0 -0
  91. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_forward_offline.py +0 -0
  92. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_incident_hackerbot_claw.py +0 -0
  93. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_ingest.py +0 -0
  94. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_injection_offline.py +0 -0
  95. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_normalize.py +0 -0
  96. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_provenance.py +0 -0
  97. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_response.py +0 -0
  98. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_rubric.py +0 -0
  99. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_schema.py +0 -0
  100. {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_subscription.py +0 -0
@@ -29,13 +29,13 @@ jobs:
29
29
  matrix:
30
30
  python-version: ["3.11", "3.13"]
31
31
  steps:
32
- - uses: actions/checkout@v4
32
+ - uses: actions/checkout@v7
33
33
 
34
34
  - name: Install uv
35
35
  # Third-party action pinned to a commit SHA for supply-chain integrity (a moving major tag
36
36
  # could be repointed at malicious code that runs with this workflow's token). GitHub-owned
37
37
  # actions (checkout) stay on a major tag. Bump the SHA and the trailing version comment together.
38
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
38
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
39
39
  with:
40
40
  enable-cache: true
41
41
 
@@ -75,7 +75,7 @@ jobs:
75
75
  name: self-scan (dogfood)
76
76
  runs-on: ubuntu-latest
77
77
  steps:
78
- - uses: actions/checkout@v4
78
+ - uses: actions/checkout@v7
79
79
  with:
80
80
  fetch-depth: 0 # the action needs the base commit to diff against
81
81
 
@@ -110,9 +110,9 @@ jobs:
110
110
  name: consumer install path (PyPI)
111
111
  runs-on: ubuntu-latest
112
112
  steps:
113
- - uses: actions/checkout@v4
113
+ - uses: actions/checkout@v7
114
114
  - name: Install uv
115
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
115
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
116
116
  with:
117
117
  enable-cache: true
118
118
  - name: The released package installs from PyPI and flags a malicious diff
@@ -134,9 +134,9 @@ jobs:
134
134
  name: ruff
135
135
  runs-on: ubuntu-latest
136
136
  steps:
137
- - uses: actions/checkout@v4
137
+ - uses: actions/checkout@v7
138
138
  - name: Install uv
139
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
139
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
140
140
  with:
141
141
  enable-cache: true
142
142
  - name: Lint
@@ -26,10 +26,10 @@ jobs:
26
26
  build:
27
27
  runs-on: ubuntu-latest
28
28
  steps:
29
- - uses: actions/checkout@v4
29
+ - uses: actions/checkout@v7
30
30
 
31
31
  - name: Install uv
32
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
32
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
33
33
 
34
34
  - name: Build sdist + wheel
35
35
  run: uv build
@@ -5,6 +5,110 @@ malicious intent — the diff held as **untrusted data** in a sandboxed REPL, a
5
5
  and deterministic indicator evidence unioned on read into a SIEM signal — as a traced, improvable RLM
6
6
  framework on [`rlm-harness`](https://github.com/qazbnm456/rlm-harness) (a BewAIre-style detector).
7
7
 
8
+ ## 0.4.3
9
+
10
+ Dependencies. `rlm-harness` moves from `1.0.0` to `1.10.1` — ten minor releases of the kit — and `dspy`
11
+ from `3.2.1` to `3.3.1` with it. Nothing in the `diff_sentry` package changed: every kit name this
12
+ project imports was checked against 1.10.1 by name, both offline suites, the studio suite and the node
13
+ tests pass on the new pin, and a live classification ran clean. The one real risk, a doubled
14
+ `sub_call`, was tested for before the bump and is not there (below).
15
+
16
+ ### Changed
17
+ - **`rlm-harness==1.10.1`, `dspy` 3.3.1.** The pin stays exact. dspy 3.3 drops `asyncer`, `typeguard`,
18
+ `numpy` and `xxhash` from its exact pins, so a fresh install resolves fewer packages, not more.
19
+ - **The trace carries more of the run; nothing here reads it differently.** `run_start` records the kit
20
+ version that wrote it (`rlm_harness`); `run_end` gains `budgets` (the generation cap actually APPLIED
21
+ per role, plus the iteration caps and whether dspy accepted them — `dropped: true` means all three
22
+ reverted to dspy's defaults, so exclude those runs before any claim about iteration exhaustion),
23
+ `usage` (completion tokens per ATTEMPT, not per turn — a distribution over runs wants the per-run
24
+ `max`, never the sum) and `error_chain` (the causes below a failed run's outer exception). Every tool
25
+ call and `sub_call` carries a measured `duration_s`. `run_labels`, `run_metrics`, the rubric facts
26
+ and the SIEM signal derivation are untouched; the new fields ride along for a future reader.
27
+ - **A non-retryable LM error fails fast, as itself.** An auth / billing / configuration /
28
+ unsupported-model error from dspy escapes the kit after ONE attempt as the original `dspy.LMError`
29
+ subclass instead of burning the retry budget and arriving wrapped in `RLMTaskError`. `cli.run` already
30
+ turns every exception into a `status=failed` response, so the never-raises contract holds; the
31
+ response now names the dspy class, which tells "fix the credential" apart from "the model kept
32
+ producing invalid output". `RLMTaskError` still means the latter, or a retryable endpoint fault
33
+ (timeout / 5xx / transport) that outlasted the single attempt.
34
+ - **The studio's "took" label on a tool call reads the kit's own timing.** The kit now measures each
35
+ call; the console used to estimate a tool's duration as the gap since the previous live event, which
36
+ charged the whole preceding planner turn to a sub-millisecond scan. It prefers the recorded
37
+ `duration_s` and falls back to the gap for a trace that predates it.
38
+
39
+ ### Fixed (upstream, reaching this project)
40
+ - **An analyst escalation is recorded once.** Since kit 1.7.0 a plain `sub_lm` is wrapped for tracing
41
+ automatically. This project already wraps the analyst with `intercept_sub_lm`, whose wrapper declares
42
+ `records_sub_call = True`; the kit honours that opt-out and leaves it alone, so a `sub_call` lands in
43
+ the trace — and in the exported datasets — exactly once. Verified at runtime and on a live run.
44
+ - **`intercept_sub_lm` handles dspy's typed `LMResponse`.** Under dspy 3.3 a sub-LM can return an
45
+ `LMResponse` rather than a list of strings, and the 1.0.0 wrapper mishandled it. The analyst intercept
46
+ is that wrapper.
47
+ - **A lone surrogate in a payload no longer loses the event.** The change under review is
48
+ attacker-authored text; a payload carrying an unpaired surrogate used to fail the recorder's JSON dump
49
+ and drop the event. It is written now.
50
+
51
+ ### Docs
52
+ - **`CLAUDE.md` names all five version sites** and which of them `release.yml` gates (the README's
53
+ `uses:` example is the one it does not), and its `RLMTaskError` guidance matches the fail-fast
54
+ behaviour above.
55
+
56
+ ## 0.4.2
57
+
58
+ Scoping. Every fix here comes from the same root: the scan read a whole change as one
59
+ string, so rules that need two signals could take them from two unrelated files.
60
+
61
+ ### Fixed
62
+ - **The scan is scoped per file.** `scan_indicators` reads whatever it is handed as one string, and the
63
+ action hands it the whole `git diff`. Most rules fire on a single match, so that was harmless — but
64
+ two need a PAIR of signals (`pwn-request`: privileged trigger + PR-HEAD checkout;
65
+ `detached-process-spawn`: detached spawn + `child_process`), and a whole-blob read let those pair
66
+ ACROSS FILES. A `workflow_run:` added to one workflow plus a `ref: ${{ ... head.sha }}` sitting in a
67
+ doc example, a test fixture, or the very workflow a change is *deleting*, composed into a `critical`
68
+ that no single file contained. The new `scan_diff` splits a unified diff on its file headers and
69
+ scans each segment on its own; non-diff input (a region the planner pulled out of the REPL, a plain
70
+ file) falls through to the previous whole-text scan. Used by `scan` and by the in-loop
71
+ `scan_indicators` tool.
72
+ - **The host-side baseline is scoped per file too, via `scan_content`.** An event is not a unified diff:
73
+ `raw_content` concatenates the title, every `(filename, patch)` and the body with no `diff --git`
74
+ headers, so pointing the baseline at `scan_diff` would have scoped nothing at all. `scan_content`
75
+ splits on the structure the event already carries — and gives hits from a file that file's name. The
76
+ case is not hypothetical: a PR that adds a `workflow_run` workflow AND documents the trap in the same
77
+ commit is what a careful repo writes, and read as one blob it came out `critical`. `content_segments`
78
+ is now the single definition `raw_content` is built on, so the two cannot drift, and the
79
+ detection-quality corpus was moved onto the production path so it guards what actually ships.
80
+
81
+ ### Changed
82
+ - **`astral-sh/setup-uv` bumped to v10.0.1** (from v5.4.2) and **`actions/checkout` to v7**, both for
83
+ the Node 24 runtime — between them they clear GitHub's Node 20 deprecation warning. The setup-uv bump
84
+ covers `action.yml` too, so it reaches every repo running this action, not just this one's CI. Five
85
+ majors in one
86
+ jump, so what actually applies here was checked: v8 stopped publishing major tags (this repo already
87
+ SHA-pins), v9 flipped the `prune-cache` default, and v10 disables caching for `release` /
88
+ `workflow_run` / `pull_request_target` events under `enable-cache: auto` — CI passes `enable-cache:
89
+ true` explicitly and keeps its cache, and the release job never wanted one.
90
+
91
+ checkout v7 also **blocks checking out a fork PR head under `pull_request_target` / `workflow_run`** —
92
+ the `pwn-request` shape this project exists to detect, now refused by the action itself. Nothing here
93
+ checks out a PR head, so the bump is behaviour-neutral for these workflows and strictly better posture
94
+ for a detector to be running.
95
+
96
+ ### Added
97
+ - **`pwn-request` covers `allow-unsafe-pr-checkout: true`.** actions/checkout v5.1/v6.1/v7 refuse to
98
+ check out a fork PR head under `pull_request_target` / `workflow_run` unless a workflow opts back in
99
+ with that input. The opt-in is the same attack stated in words — no ref to trace, no dataflow to
100
+ infer — so it joins the `ref:` value and the hand-rolled `git fetch` as the rule's third form, with
101
+ its own title so a reader is not sent hunting for a `ref:` that is not there. It only counts under a
102
+ privileged trigger (beneath `pull_request` the input carries none of that meaning), a removed opt-in
103
+ does not fire, and neither does an explicit `false`.
104
+ - **Hits name the file they came from.** `location` was the name of the whole diff — `gated.diff` for
105
+ every hit, which meant reading the raw log to find out where a finding actually was. Per-file
106
+ scanning gives each hit its real path.
107
+ - **A hit id is now stable between a whole-diff scan and a single-file scan.** The segment the planner
108
+ pulls out is byte-identical to the one the baseline scanned, so the evidence snippets — and therefore
109
+ the ids — match, and the two de-duplicate to one union member on read. Whole-blob scanning could not
110
+ promise that: the snippet window moved with the byte offset.
111
+
8
112
  ## 0.4.1
9
113
 
10
114
  A correctness release for one rule: `pwn-request` was failing the very pattern this
@@ -4,7 +4,7 @@ A **BewAIre-style malicious-change detector** built on `rlm-harness` — a downs
4
4
  scaffold. It classifies ONE GitHub change (PR/issue/push)
5
5
  for malicious intent: the diff is UNTRUSTED DATA held in a sandboxed REPL, the planner SUBMITs a
6
6
  judgement-only verdict, and the deterministic indicator EVIDENCE is unioned on read into a SIEM signal.
7
- rlm-harness is consumed as an **exact PyPI pin** (`rlm-harness==1.0.0`, resolved from the index and
7
+ rlm-harness is consumed as an **exact PyPI pin** (`rlm-harness==1.10.1`, resolved from the index and
8
8
  recorded in `uv.lock`); overlay `uv pip install -e ../rlm-harness` only when co-developing the kit
9
9
  locally, and bump the pin once the change ships. See
10
10
  `README.md` for the pipeline table + the honest caveats (prompt-injection residual, deep-tier scale),
@@ -100,6 +100,22 @@ One companion rule ships under `.claude/rules/`:
100
100
  network-fetch + file-write — are `high`. A rule that needs BOTH halves is how a common primitive
101
101
  becomes evidence without becoming noise. The corpus pins this hit/miss behavior, and every rule ships
102
102
  with its NEGATIVE case. Hit evidence is a BOUNDED snippet (`_MAX_EVIDENCE`), never the whole diff.
103
+ - **`pwn-request` has THREE checkout forms**, and all three need the privileged trigger to count: the
104
+ expression on a `ref:` (directly or one binding hop away), a hand-rolled `git fetch`/`gh pr checkout`,
105
+ and `allow-unsafe-pr-checkout: true` — the input actions/checkout v5.1+ added for opting back into
106
+ the fork-PR checkout it now blocks. The last one needs no dataflow: the line states the intent. Each
107
+ form carries its own title, because reporting an opt-in as "checks out the PR HEAD" sends a reader
108
+ hunting for a `ref:` that is not there.
109
+ - **A paired rule only pairs WITHIN one file.** `scan_diff` splits a unified diff on its file headers;
110
+ `scan_content` does the same for an EVENT, whose files `raw_content` would otherwise concatenate into
111
+ one header-less blob that `scan_diff` cannot scope at all. `scan` and the in-loop tool take the first,
112
+ the host-side baseline takes the second, and `content_segments` is the single definition both
113
+ `raw_content` and the baseline are built on, so they cannot drift.
114
+ Scanning the diff as one blob let the two halves come from different files — a `workflow_run:` in one
115
+ workflow and a `ref: ${{ … head.sha }}` in a doc example composed into a `critical` no file contained.
116
+ It also makes a hit id stable between a whole-diff scan and a single-file one, because the segment is
117
+ byte-identical either way, which is what lets the baseline and an in-loop scan de-duplicate (MF3).
118
+ Non-diff input falls through to a plain whole-text scan; keep that fallback.
103
119
  - **The indicator tool registers as exactly `scan_indicators`.** dspy registers a tool under its
104
120
  `__name__` and the prompt says `scan_indicators(region)` — a rename makes every sandbox call a
105
121
  NameError (a shipped regression; see the `fix(detect)` commit). The inner callable is renamed on
@@ -108,8 +124,12 @@ One companion rule ships under `.claude/rules/`:
108
124
  `DS_CLASSIFIER_LM` classifier, defaulting to the analyst). Refer to them by role in code, docs, and
109
125
  the prompt. No hardcoded model name.
110
126
  - **The budget is HARD — `max_retries=1`, no whole-RLM retry** (set in `detect.setup`). One change =
111
- one trajectory, so the trace stays valid training data. An `RLMTaskError` is almost always infra (a
112
- planner-endpoint hiccup / an adapter parse failure), NOT a schema bug — check the endpoint first.
127
+ one trajectory, so the trace stays valid training data. A failed run is INFRA before it is a schema
128
+ bug — check the endpoint first. Since rlm-harness 1.2.1 the failure names itself: a non-retryable
129
+ LM error (auth / billing / configuration / unsupported model) escapes after ONE attempt as the raw
130
+ `dspy.LMError` subclass, while an `RLMTaskError` means the single attempt ended in an adapter parse
131
+ failure, an invalid result, or a retryable endpoint fault (timeout / 5xx / transport). `cli.run`
132
+ catches BOTH into a `status=failed` response.
113
133
  - **A second model-judgement is a TOOL, never the sub-LM.** `deep_classify` is the swappable
114
134
  second-stage SEAM, built on rlm-harness's `make_model_tool` (chat → transient-retry → validate →
115
135
  circuit-break) — the planner CHOOSES to consult it, so the decision is a `tool_call` in the
@@ -198,4 +218,9 @@ One companion rule ships under `.claude/rules/`:
198
218
 
199
219
  ## Versioning
200
220
 
201
- - Keep `pyproject.toml` `[project].version` and `diff_sentry.__version__` in sync.
221
+ - A release moves FIVE version sites together: `pyproject.toml` `[project].version`,
222
+ `diff_sentry.__version__`, `action.yml`'s `version` input default, the README quickstart's
223
+ `uses: qazbnm456/diff-sentry@vX.Y.Z`, and `uv.lock` (via `uv lock`) — plus a `CHANGELOG.md` entry.
224
+ `release.yml` gates the first three against the tag and the built wheel; the README `uses:` line is
225
+ gated by nothing, so a stale one sends new adopters to the previous release. Publishing the GitHub
226
+ Release (tag `vX.Y.Z`) is what builds, verifies, and uploads to PyPI, and what Marketplace lists.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diff-sentry
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Catch malicious pull requests before they merge: scan a diff for supply-chain attack patterns with deterministic evidence a model cannot suppress.
5
5
  Author: Boik Su
6
6
  License-Expression: MIT
@@ -9,7 +9,7 @@ Keywords: dspy,malicious-code-detection,prompt-injection,rlm,security,siem,suppl
9
9
  Requires-Python: >=3.11
10
10
  Requires-Dist: httpx>=0.27
11
11
  Requires-Dist: pydantic>=2.0
12
- Requires-Dist: rlm-harness==1.0.0
12
+ Requires-Dist: rlm-harness==1.10.1
13
13
  Provides-Extra: classify
14
14
  Requires-Dist: openai>=1.0; extra == 'classify'
15
15
  Provides-Extra: observe
@@ -44,7 +44,7 @@ jobs:
44
44
  - uses: actions/checkout@v4
45
45
  with:
46
46
  fetch-depth: 0 # the scan diffs against the PR base
47
- - uses: qazbnm456/diff-sentry@v0.4.1
47
+ - uses: qazbnm456/diff-sentry@v0.4.3
48
48
  ```
49
49
 
50
50
  That is the whole setup. The job fails when an indicator at or above `high` fires.
@@ -53,7 +53,7 @@ That is the whole setup. The job fails when an indicator at or above `high` fire
53
53
 
54
54
  | Family | Examples |
55
55
  |---|---|
56
- | Workflow configuration | `pull_request_target` that checks out the PR head (the pwn-request that broke AsyncAPI), `permissions: write-all`, CODEOWNERS reassignment |
56
+ | Workflow configuration | `pull_request_target` that checks out the PR head (the pwn-request that broke AsyncAPI) — by `ref:`, by a hand-rolled `git fetch`, or by opting back in with `allow-unsafe-pr-checkout: true` — plus `permissions: write-all` and CODEOWNERS reassignment |
57
57
  | Obfuscation | base64 that decodes to a shell payload, `_0x…` machine-mangled JavaScript, high-entropy blobs |
58
58
  | Execution | pipe-to-shell installs, `${IFS}` space-evasion, detached child processes, inline `node -e`, fetch-to-disk droppers |
59
59
  | Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
@@ -23,7 +23,7 @@ jobs:
23
23
  - uses: actions/checkout@v4
24
24
  with:
25
25
  fetch-depth: 0 # the scan diffs against the PR base
26
- - uses: qazbnm456/diff-sentry@v0.4.1
26
+ - uses: qazbnm456/diff-sentry@v0.4.3
27
27
  ```
28
28
 
29
29
  That is the whole setup. The job fails when an indicator at or above `high` fires.
@@ -32,7 +32,7 @@ That is the whole setup. The job fails when an indicator at or above `high` fire
32
32
 
33
33
  | Family | Examples |
34
34
  |---|---|
35
- | Workflow configuration | `pull_request_target` that checks out the PR head (the pwn-request that broke AsyncAPI), `permissions: write-all`, CODEOWNERS reassignment |
35
+ | Workflow configuration | `pull_request_target` that checks out the PR head (the pwn-request that broke AsyncAPI) — by `ref:`, by a hand-rolled `git fetch`, or by opting back in with `allow-unsafe-pr-checkout: true` — plus `permissions: write-all` and CODEOWNERS reassignment |
36
36
  | Obfuscation | base64 that decodes to a shell payload, `_0x…` machine-mangled JavaScript, high-entropy blobs |
37
37
  | Execution | pipe-to-shell installs, `${IFS}` space-evasion, detached child processes, inline `node -e`, fetch-to-disk droppers |
38
38
  | Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
@@ -44,7 +44,7 @@ inputs:
44
44
  # KEEP IN STEP WITH [project].version. release.yml fails the build if this drifts
45
45
  # from the tag being released, because a stale default here would silently run an
46
46
  # old scanner under a new action tag.
47
- default: '0.4.1'
47
+ default: '0.4.3'
48
48
 
49
49
  outputs:
50
50
  failed:
@@ -69,7 +69,7 @@ runs:
69
69
  - name: Install uv
70
70
  # SHA-pinned: a moving major tag on a third-party action could be repointed at code that runs
71
71
  # with the calling workflow's token. Bump the SHA and the trailing version comment together.
72
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
72
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
73
73
  with:
74
74
  enable-cache: true
75
75
 
@@ -22,7 +22,7 @@ from __future__ import annotations
22
22
  from .assemble import assemble_verdict, verdict_from_events
23
23
  from .config import DetectConfig
24
24
  from .emit import EmitResult, emit_signal, signal_payload
25
- from .indicators import hits_from_events, scan_indicators
25
+ from .indicators import hits_from_events, scan_content, scan_diff, scan_indicators
26
26
  from .ingest import event_from_payload, issue_event, pr_event
27
27
  from .normalize import event_metadata, normalize_event
28
28
  from .response import build_failed_response, build_response
@@ -44,6 +44,8 @@ __all__ = [
44
44
  "DetectionResponse",
45
45
  "RefusalInfo",
46
46
  "ProcessInfo",
47
+ "scan_content",
48
+ "scan_diff",
47
49
  "scan_indicators",
48
50
  "hits_from_events",
49
51
  "normalize_event",
@@ -68,7 +70,7 @@ __all__ = [
68
70
  "detect_from_event",
69
71
  ]
70
72
 
71
- __version__ = "0.4.1"
73
+ __version__ = "0.4.3"
72
74
 
73
75
 
74
76
  def __getattr__(name: str): # PEP 562 — defer the dspy import to first use
@@ -91,10 +91,9 @@ def _baseline_indicators(event: dict) -> list[dict]:
91
91
  """The host-side deterministic evidence recorded in run_start meta BEFORE the planner runs: the text
92
92
  detectors over the raw content ∪ the provenance detectors over the ingest facts. Both re-source into the
93
93
  evidence union on read (MF3), so a false-benign SUBMIT can skew the verdict but never suppress these."""
94
- from .indicators import scan_indicators, scan_provenance
95
- from .normalize import raw_content
94
+ from .indicators import scan_content, scan_provenance
96
95
 
97
- return ([h.model_dump() for h in scan_indicators(raw_content(event))]
96
+ return ([h.model_dump() for h in scan_content(event)]
98
97
  + [h.model_dump() for h in scan_provenance(event.get("provenance") or {})])
99
98
 
100
99
 
@@ -245,7 +244,7 @@ def _cmd_scan(args) -> int:
245
244
  `DS_*` creds, and the only way to hand creds to a fork PR's run is `pull_request_target` — the exact
246
245
  misconfiguration that opened the AsyncAPI "Miasma" supply-chain compromise. `scan` needs nothing, so
247
246
  it runs under the same read-only `contents: read` token an untrusted fork PR already gets."""
248
- from .indicators import scan_indicators
247
+ from .indicators import scan_diff
249
248
  from .schema import severity_rank
250
249
 
251
250
  threshold = severity_rank(args.fail_on)
@@ -259,7 +258,7 @@ def _cmd_scan(args) -> int:
259
258
  label = path
260
259
  if not args.include_deletions:
261
260
  text = drop_deleted_lines(text)
262
- hits.extend(scan_indicators(text, location=label))
261
+ hits.extend(scan_diff(text, location=label))
263
262
 
264
263
  worst = max((severity_rank(h.severity) for h in hits), default=-1)
265
264
  failed = worst >= threshold
@@ -72,7 +72,8 @@ class DetectConfig:
72
72
  # Generous, NOT None: a reasoning planner truncated before its answer returns empty content.
73
73
  planner_max_tokens: int | None = 16384
74
74
  # HARD ceiling on the single RLM episode. No outer multi-run loop (max_retries=1) — one change =
75
- # one trajectory, so the trace stays valid training data. RLMTaskError is INFRA, not a schema bug.
75
+ # one trajectory, so the trace stays valid training data. A failed run is INFRA, not a schema bug:
76
+ # a non-retryable LM error escapes as the raw dspy.LMError (kit >= 1.2.1), the rest as RLMTaskError.
76
77
  max_iterations: int = 25
77
78
  max_llm_calls: int = 8 # caps ONLY analyst (llm_query) escalations
78
79
  max_output_chars: int = 10_000 # head+tail char cap dspy.RLM applies to each REPL output
@@ -156,6 +156,17 @@ _HEAD_BINDING_RE = re.compile(
156
156
  rf"^[+ ]?\s*([A-Za-z_][A-Za-z0-9_-]*)\s*[:=]\s*[^\n]*(?:{_PR_HEAD_EXPR})",
157
157
  re.IGNORECASE | re.MULTILINE)
158
158
  _REF_VALUE_RE = re.compile(r"^[+ ]?\s*ref\s*:\s*([^\n]*)", re.IGNORECASE | re.MULTILINE)
159
+
160
+ # The third way to check out a fork PR head, and the only one that says so in words. actions/checkout
161
+ # v5.1/v6.1/v7 REFUSE to check out a fork PR under `pull_request_target`/`workflow_run` unless the
162
+ # workflow opts back in with `allow-unsafe-pr-checkout: true`. There is no ref to trace and no dataflow
163
+ # to infer — the line is the intent, written out. Under `pull_request` it means nothing (no privileged
164
+ # context to protect), which is why it only counts inside `_scan_workflow_config`'s trigger guard.
165
+ # Literal `true` only: an expression value is a parameterisation question, not evidence, and the
166
+ # `^[+ ]?` anchor keeps a REMOVED opt-in (the hardening you want) from firing.
167
+ _UNSAFE_PR_CHECKOUT_RE = re.compile(
168
+ r"^[+ ]?\s*allow-unsafe-pr-checkout\s*:\s*[\"']?true[\"']?\s*$",
169
+ re.IGNORECASE | re.MULTILINE)
159
170
  _IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*")
160
171
 
161
172
  # Diff-PRESENTATION evasion — attacks aimed at the human reading the diff, not at the runtime. Miasma
@@ -308,18 +319,31 @@ def _scan_workflow_perms(text: str) -> list[IndicatorHit]:
308
319
  for m in _WRITE_ALL_RE.finditer(text)]
309
320
 
310
321
 
311
- def _pr_head_checkout(text: str) -> re.Match[str] | None:
312
- """Where a PR-HEAD expression actually reaches a checkout — directly, or through one binding."""
322
+ _PWN_TITLE = ("privileged fork trigger checks out the PR HEAD — untrusted code with the base "
323
+ "repo's token/secrets")
324
+ _PWN_OPTIN_TITLE = ("privileged fork trigger re-enables the fork-PR checkout actions/checkout now "
325
+ "blocks by default — untrusted code with the base repo's token/secrets")
326
+
327
+
328
+ def _pr_head_checkout(text: str) -> tuple[re.Match[str], str] | None:
329
+ """Where a PR-HEAD checkout actually happens, and which of the three forms it took.
330
+
331
+ Returns the match to cite plus the title for it — the opt-in reads differently enough from a ref
332
+ that reporting it as "checks out the PR HEAD" would send a reader looking for a `ref:` that is not
333
+ there."""
334
+ optin = _UNSAFE_PR_CHECKOUT_RE.search(text)
335
+ if optin:
336
+ return optin, _PWN_OPTIN_TITLE
313
337
  direct = _PR_HEAD_CHECKOUT_RE.search(text)
314
338
  if direct:
315
- return direct
339
+ return direct, _PWN_TITLE
316
340
  # `ref:` is not a binding — a `ref:` holding the expression IS the direct case above.
317
341
  bound = {m.group(1).lower() for m in _HEAD_BINDING_RE.finditer(text)} - {"ref"}
318
342
  if not bound:
319
343
  return None
320
344
  for m in _REF_VALUE_RE.finditer(text):
321
345
  if any(name.lower() in bound for name in _IDENT_RE.findall(m.group(1))):
322
- return m
346
+ return m, _PWN_TITLE
323
347
  return None
324
348
 
325
349
 
@@ -330,10 +354,9 @@ def _scan_workflow_config(text: str) -> list[IndicatorHit]:
330
354
  return []
331
355
  checkout = _pr_head_checkout(text)
332
356
  if checkout:
333
- return [_hit("pwn-request", "critical",
334
- "privileged fork trigger checks out the PR HEAD — untrusted code with the base "
335
- "repo's token/secrets",
336
- _snip(text, checkout.start(), checkout.end()))]
357
+ match, title = checkout
358
+ return [_hit("pwn-request", "critical", title,
359
+ _snip(text, match.start(), match.end()))]
337
360
  head = _PR_HEAD_REF_RE.search(text)
338
361
  if head:
339
362
  # Reads the head ref without checking it out. That is the validating-publisher shape, which is
@@ -442,6 +465,72 @@ def _sev_rank(sev: str) -> int:
442
465
  return severity_rank(sev)
443
466
 
444
467
 
468
+ # ── diff SCOPING ──────────────────────────────────────────────────────────────────────────────────────
469
+ # A change is many files, and `scan_indicators` reads whatever it is handed as ONE string. That is right
470
+ # for the rules that fire on a single match, but two of them require a PAIR of signals — `pwn-request`
471
+ # (privileged trigger + PR-HEAD checkout) and `detached-process-spawn` (detached spawn + child_process).
472
+ # Read as one blob, those pair ACROSS FILES: a `workflow_run:` added to one workflow and a
473
+ # `ref: ${{ github.event.pull_request.head.sha }}` sitting in an unrelated file — a doc example, a test
474
+ # fixture, or the very workflow the change is DELETING — compose into a `critical` that no single file
475
+ # contains. Scoping the scan per file removes that class outright, and names the file in `location`
476
+ # while it is there: evidence that used to read `gated.diff` for every hit now points at what to open.
477
+ #
478
+ # The trade is explicit: a payload deliberately split across two files no longer pairs. That is the
479
+ # right way round — a detector that invents a critical out of two unrelated files trains people to
480
+ # disable it, and the single-signal rules (which are most of them) are unaffected either way.
481
+ _DIFF_FILE_RE = re.compile(r"^diff --git a/(?P<a>.+?) b/(?P<b>.+?)$", re.MULTILINE)
482
+
483
+
484
+ def split_diff_by_file(text: str) -> list[tuple[str, str]]:
485
+ """Split a unified diff into `(path, segment)` pairs; `[]` when `text` is not a diff.
486
+
487
+ Anything BEFORE the first file header comes back first with an empty path. That chunk is not
488
+ padding — for `raw_content` it is the PR title and body, which is exactly where prompt injection
489
+ lives, so it has to keep being scanned rather than skipped as preamble."""
490
+ marks = list(_DIFF_FILE_RE.finditer(text or ""))
491
+ if not marks:
492
+ return []
493
+ out: list[tuple[str, str]] = []
494
+ head = text[:marks[0].start()]
495
+ if head.strip():
496
+ out.append(("", head))
497
+ for i, m in enumerate(marks):
498
+ end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
499
+ out.append((m.group("b") or m.group("a"), text[m.start():end]))
500
+ return out
501
+
502
+
503
+ def scan_content(event: dict) -> list[IndicatorHit]:
504
+ """Scan ONE event's untrusted content with the same per-file scoping `scan_diff` gives a raw diff.
505
+
506
+ An event is not a unified diff — `raw_content` concatenates the title, the author, every
507
+ (filename, patch) and the body into one string, with no `diff --git` headers for `scan_diff` to
508
+ split on. Handing that blob to `scan_diff` therefore scopes NOTHING: a paired rule could still take
509
+ one half from a workflow and the other from an unrelated file. The event already carries the
510
+ structure a raw diff has to be parsed for, so scan it piece by piece and let hits from a file name
511
+ that file."""
512
+ from .normalize import content_segments
513
+ deduped: dict[str, IndicatorHit] = {}
514
+ for label, text in content_segments(event):
515
+ for h in scan_indicators(text, location=label):
516
+ deduped.setdefault(h.id, h)
517
+ return list(deduped.values())
518
+
519
+
520
+ def scan_diff(text: str, *, location: str = "") -> list[IndicatorHit]:
521
+ """`scan_indicators` with per-file scoping — a paired rule can only fire when ONE file carries both
522
+ halves. Non-diff input (a region the planner pulled out, a plain file) falls through to a single
523
+ whole-text scan, so this is safe as the entry point for either shape."""
524
+ segments = split_diff_by_file(text)
525
+ if not segments:
526
+ return scan_indicators(text, location=location)
527
+ deduped: dict[str, IndicatorHit] = {}
528
+ for path, segment in segments:
529
+ for h in scan_indicators(segment, location=path or location):
530
+ deduped.setdefault(h.id, h)
531
+ return list(deduped.values())
532
+
533
+
445
534
  def scan_indicators(text: str, *, location: str = "") -> list[IndicatorHit]:
446
535
  """Run every detector over `text` and return the DEDUPED union of hits (by deterministic id).
447
536
 
@@ -589,7 +678,11 @@ def make_indicator_tool() -> Callable[[str], str]:
589
678
  injection, obfuscated payloads, secret exfiltration, known exfil/OAST callback services,
590
679
  CI/CODEOWNERS tampering, workflow permission escalation, CI-skip bypass, prompt injection. Returns
591
680
  the hits found (id, rule, severity, title). Cite an id in your final `indicator_ids`."""
592
- hits = scan_indicators(region or "")
681
+ # scan_diff, not scan_indicators: when the planner hands back a whole diff this scopes it per
682
+ # file exactly as the host-side baseline did, so the two scans of the same content still mint
683
+ # the same ids and de-duplicate to one union member (MF3). A sub-region is not a diff and falls
684
+ # through to the plain whole-text scan.
685
+ hits = scan_diff(region or "")
593
686
  record_tool_call("scan_indicators", args={"region": (region or "")[:200]}, ok=True,
594
687
  hits=[h.model_dump() for h in hits], n=len(hits))
595
688
  if not hits:
@@ -159,13 +159,25 @@ def has_groundable_content(event_str: str) -> bool:
159
159
  return bool(body) and body != NO_CONTENT_SENTINEL
160
160
 
161
161
 
162
+ def content_segments(event: dict) -> list[tuple[str, str]]:
163
+ """The untrusted content split the way the event already carries it: `(label, text)` per piece —
164
+ the title+author, then one segment per file, then the body.
165
+
166
+ `raw_content` is the concatenation of exactly these, in this order, so the two cannot drift and the
167
+ content digest built on it stays byte-stable. The split exists because the baseline scan needs the
168
+ same per-file scoping `scan_diff` gives a raw diff: read as one blob, a rule that needs two signals
169
+ could take one from a workflow and the other from an unrelated file. A file's segment keeps its
170
+ filename INSIDE the text as well as in the label — `workflow-tamper` reads the path itself."""
171
+ files = _files(event)
172
+ segments = [("", f"{event.get('title', '') or ''}\n{event.get('author', '') or ''}")]
173
+ segments += [(f["filename"], f"{f['filename']}\n{f['patch']}") for f in files]
174
+ segments.append(("", str(event.get("body", "") or "")))
175
+ return segments
176
+
177
+
162
178
  def raw_content(event: dict) -> str:
163
179
  """The concatenated untrusted content the host-side BASELINE scan runs on — TITLE + author +
164
180
  (filename + patch) per file + body. The title is included so a title-borne prompt injection is caught
165
181
  by the deterministic baseline even when the planner is skewed by the same payload (MF3): an issue/PR
166
182
  title is a canonical injection channel, and its hits must reach the signal regardless of the verdict."""
167
- files = _files(event)
168
- parts = [str(event.get("title", "") or ""), str(event.get("author", "") or "")]
169
- parts += [f"{f['filename']}\n{f['patch']}" for f in files]
170
- parts.append(str(event.get("body", "") or ""))
171
- return "\n".join(parts)
183
+ return "\n".join(text for _, text in content_segments(event))
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "diff-sentry"
3
- version = "0.4.1"
3
+ version = "0.4.3"
4
4
  description = "Catch malicious pull requests before they merge: scan a diff for supply-chain attack patterns with deterministic evidence a model cannot suppress."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -8,8 +8,8 @@ license = "MIT"
8
8
  authors = [{ name = "Boik Su" }]
9
9
  keywords = ["rlm", "dspy", "security", "malicious-code-detection", "supply-chain-security", "prompt-injection", "siem"]
10
10
  dependencies = [
11
- # Pinned exactly: 1.0.0 freezes the kit's public surface under SemVer.
12
- "rlm-harness==1.0.0",
11
+ # Pinned exactly (the kit's public surface has been SemVer-frozen since 1.0.0); bump deliberately.
12
+ "rlm-harness==1.10.1",
13
13
  "pydantic>=2.0",
14
14
  "httpx>=0.27", # the GitHub-allowlisted enrichment fetcher + the host-side SIEM emitter
15
15
  ]
@@ -57,11 +57,19 @@ def _gap(ts: float | None, prev: float | None) -> float | None:
57
57
  return round(ts - prev, 3) if (ts is not None and prev is not None) else None
58
58
 
59
59
 
60
+ def _took(p: dict, gap: float | None) -> float | None:
61
+ """How long a tool / analyst call took. rlm-harness >= 1.8.3 measures every call and writes
62
+ `payload.duration_s`; prefer that. The gap since the previous live event is only a FALLBACK for a
63
+ trace that predates it — it charges the whole preceding planner turn to a sub-millisecond scan."""
64
+ d = p.get("duration_s")
65
+ return round(float(d), 6) if isinstance(d, (int, float)) and not isinstance(d, bool) else gap
66
+
67
+
60
68
  def _tool_entry(p: dict, gap: float | None) -> dict:
61
69
  """One tool_call → a UI-ready entry: a label, the input it was given, the output it returned."""
62
70
  tool = p.get("tool")
63
71
  args = p.get("args") or {}
64
- e: dict = {"kind": "tool", "tool": tool, "ok": p.get("ok"), "duration_s": gap}
72
+ e: dict = {"kind": "tool", "tool": tool, "ok": p.get("ok"), "duration_s": _took(p, gap)}
65
73
  if tool == "scan_indicators":
66
74
  hits = p.get("hits") or []
67
75
  e.update(label="scan", target=_preview(args.get("region")), n=p.get("n") or len(hits),
@@ -92,7 +100,7 @@ def _tool_entry(p: dict, gap: float | None) -> dict:
92
100
  def _sub_entry(p: dict, gap: float | None) -> dict:
93
101
  """One sub_call (an analyst escalation) → the distilled question + the answer it returned."""
94
102
  return {"kind": "analyst", "label": "analyst", "model": p.get("name") or p.get("model"),
95
- "duration_s": gap, "input": _preview(p.get("input")),
103
+ "duration_s": _took(p, gap), "input": _preview(p.get("input")),
96
104
  "output": _preview(p.get("processed") or p.get("raw")), "error": p.get("error")}
97
105
 
98
106
 
@@ -141,7 +149,8 @@ def build_iterations(events: list[dict]) -> dict:
141
149
  finalize, so their ts cluster at one instant; we detect that, set `per_turn_timing=False`, and skip
142
150
  per-turn durations rather than fake them.
143
151
  - `timeline` — the `tool_call`/`sub_call` events, ALWAYS recorded LIVE with real `ts`. Each entry
144
- carries `rel_s` (since run start) and `duration_s` (the gap since the previous live event). The
152
+ carries `rel_s` (since run start) and `duration_s` (the kit's measured call time, else the gap
153
+ since the previous live event). The
145
154
  accurate "where did the time go" signal either way, and the headline for debugging a slow run.
146
155
  """
147
156
  evs = sorted(events, key=_step_key)
@@ -123,7 +123,9 @@ def _describe_exc(exc: BaseException) -> str:
123
123
  """One line describing a run failure, INCLUDING its underlying cause. `RLMTaskError` reports the
124
124
  opaque "Failed to produce a valid 'result' after N attempts"; the real reason (a planner-endpoint
125
125
  error, an adapter parse failure) is on `__cause__`. Surfacing it stops an infra hiccup from reading
126
- like a content/schema problem (diff-sentry's own CLAUDE.md: an RLMTaskError is almost always infra)."""
126
+ like a content/schema problem (diff-sentry's own CLAUDE.md: a failed run is infra before it is a
127
+ schema bug). A non-retryable LM error (rlm-harness >= 1.2.1) arrives unwrapped as the raw
128
+ `dspy.LMError` subclass, so its own class name already says "fix the credential"."""
127
129
  out = f"{type(exc).__name__}: {exc}"
128
130
  cause = exc.__cause__ or exc.__context__
129
131
  if cause is not None and cause is not exc: