dos-kernel 0.22.0__py3-none-win_amd64.whl

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 (178) hide show
  1. dos/__init__.py +261 -0
  2. dos/_bin/dos-hook.exe +0 -0
  3. dos/_filelock.py +255 -0
  4. dos/_job_policy.py +97 -0
  5. dos/_tree.py +145 -0
  6. dos/admission.py +433 -0
  7. dos/answer_shape.py +299 -0
  8. dos/arbiter.py +859 -0
  9. dos/archive_lock.py +266 -0
  10. dos/arg_provenance.py +814 -0
  11. dos/attest.py +472 -0
  12. dos/breaker.py +311 -0
  13. dos/churn.py +226 -0
  14. dos/claim_extract.py +229 -0
  15. dos/claim_ttl.py +150 -0
  16. dos/cli.py +8721 -0
  17. dos/commit_audit.py +666 -0
  18. dos/completion.py +466 -0
  19. dos/concurrency_class.py +154 -0
  20. dos/config.py +1380 -0
  21. dos/config_lint.py +464 -0
  22. dos/cooldown.py +390 -0
  23. dos/coverage.py +387 -0
  24. dos/dangling_intent.py +287 -0
  25. dos/data_class.py +397 -0
  26. dos/decisions.py +1274 -0
  27. dos/decisions_tui.py +251 -0
  28. dos/dispatch_top.py +740 -0
  29. dos/dispatch_top_tui.py +116 -0
  30. dos/drivers/__init__.py +40 -0
  31. dos/drivers/ci_status.py +630 -0
  32. dos/drivers/citation_resolve.py +703 -0
  33. dos/drivers/decision_stop.py +98 -0
  34. dos/drivers/export_file.py +173 -0
  35. dos/drivers/export_otlp.py +275 -0
  36. dos/drivers/export_statsd.py +242 -0
  37. dos/drivers/hook_dialects.py +391 -0
  38. dos/drivers/job.py +47 -0
  39. dos/drivers/llm_judge.py +360 -0
  40. dos/drivers/memory_recall.py +1231 -0
  41. dos/drivers/notify_slack.py +373 -0
  42. dos/drivers/notify_webhook.py +251 -0
  43. dos/drivers/operator_judge.py +114 -0
  44. dos/drivers/os_acceptance.py +228 -0
  45. dos/drivers/paste_log.py +132 -0
  46. dos/drivers/plan_scope.py +133 -0
  47. dos/drivers/self_improve.py +375 -0
  48. dos/drivers/similarity_judge.py +249 -0
  49. dos/drivers/state_diff.py +274 -0
  50. dos/drivers/supervisor.py +347 -0
  51. dos/drivers/watchdog.py +363 -0
  52. dos/drivers/workshop.py +160 -0
  53. dos/durable_schema.py +344 -0
  54. dos/effect_witness.py +393 -0
  55. dos/efficiency.py +318 -0
  56. dos/enforce.py +414 -0
  57. dos/enumerate.py +776 -0
  58. dos/env_print.py +378 -0
  59. dos/event_severity.py +258 -0
  60. dos/evidence.py +692 -0
  61. dos/exec_capability.py +256 -0
  62. dos/export_cursor.py +143 -0
  63. dos/exporter.py +320 -0
  64. dos/firing_label.py +353 -0
  65. dos/fleet_roll.py +226 -0
  66. dos/gate_classify.py +827 -0
  67. dos/gh4_coverage.py +179 -0
  68. dos/git_delta.py +122 -0
  69. dos/guard.py +215 -0
  70. dos/health.py +552 -0
  71. dos/help_summary.py +519 -0
  72. dos/home.py +934 -0
  73. dos/hook_binary.py +194 -0
  74. dos/hook_dialect.py +271 -0
  75. dos/hook_exit.py +191 -0
  76. dos/hook_install.py +437 -0
  77. dos/id_alloc.py +304 -0
  78. dos/improve.py +499 -0
  79. dos/intent_ledger.py +635 -0
  80. dos/interpret.py +176 -0
  81. dos/intervention.py +769 -0
  82. dos/intervention_eval.py +371 -0
  83. dos/journal_delta.py +308 -0
  84. dos/judge_eval.py +328 -0
  85. dos/judges.py +366 -0
  86. dos/lane_infer.py +127 -0
  87. dos/lane_journal.py +1001 -0
  88. dos/lane_lease.py +952 -0
  89. dos/lane_overlap.py +228 -0
  90. dos/lease_health.py +282 -0
  91. dos/lifecycle.py +211 -0
  92. dos/liveness.py +352 -0
  93. dos/lock_modes.py +185 -0
  94. dos/log_source.py +395 -0
  95. dos/loop_decide.py +1746 -0
  96. dos/marker_gate.py +254 -0
  97. dos/marker_sensor.py +396 -0
  98. dos/noop_streak.py +280 -0
  99. dos/notify.py +479 -0
  100. dos/observe.py +175 -0
  101. dos/oracle.py +1661 -0
  102. dos/overlap_eval.py +214 -0
  103. dos/overlap_policy.py +342 -0
  104. dos/packet_sidecar.py +267 -0
  105. dos/phase_shipped.py +1985 -0
  106. dos/pick_priority.py +225 -0
  107. dos/pickable.py +369 -0
  108. dos/picker_oracle.py +1037 -0
  109. dos/plan_board.py +513 -0
  110. dos/plan_board_tui.py +113 -0
  111. dos/plan_source.py +455 -0
  112. dos/posttool_sensor.py +528 -0
  113. dos/precursor_gate.py +499 -0
  114. dos/precursor_gate_eval.py +239 -0
  115. dos/preflight.py +825 -0
  116. dos/pretool_sensor.py +490 -0
  117. dos/proc_delta.py +181 -0
  118. dos/productivity.py +296 -0
  119. dos/provider_limit.py +242 -0
  120. dos/py.typed +4 -0
  121. dos/reason_morphology.py +299 -0
  122. dos/reasons.py +449 -0
  123. dos/reconcile.py +173 -0
  124. dos/recurring_wedge.py +206 -0
  125. dos/render.py +393 -0
  126. dos/result_state.py +468 -0
  127. dos/resume.py +578 -0
  128. dos/resume_evidence.py +293 -0
  129. dos/retention.py +344 -0
  130. dos/reward.py +372 -0
  131. dos/rewind.py +587 -0
  132. dos/rewind_evidence.py +168 -0
  133. dos/rewind_tokens.py +252 -0
  134. dos/run_id.py +342 -0
  135. dos/scope.py +520 -0
  136. dos/scope_source.py +382 -0
  137. dos/scout.py +982 -0
  138. dos/self_modify.py +209 -0
  139. dos/sibling_scan.py +569 -0
  140. dos/skills/EXAMPLES.md +584 -0
  141. dos/skills/dos-class-cycle/SKILL.md +107 -0
  142. dos/skills/dos-dispatch/SKILL.md +177 -0
  143. dos/skills/dos-dispatch-loop/SKILL.md +254 -0
  144. dos/skills/dos-goal-gate/SKILL.md +269 -0
  145. dos/skills/dos-next-up/SKILL.md +231 -0
  146. dos/skills/dos-promote/SKILL.md +114 -0
  147. dos/skills/dos-replan/SKILL.md +159 -0
  148. dos/skills/dos-replan-loop/SKILL.md +114 -0
  149. dos/skills/dos-self-improve/SKILL.md +213 -0
  150. dos/skills/dos-supervise-loop/SKILL.md +180 -0
  151. dos/skills/dos-unstick/SKILL.md +108 -0
  152. dos/skills/dos-witness-claim/SKILL.md +251 -0
  153. dos/stamp.py +1002 -0
  154. dos/state_health.py +387 -0
  155. dos/status.py +114 -0
  156. dos/stop_policy.py +334 -0
  157. dos/supervise.py +1014 -0
  158. dos/testwitness.py +392 -0
  159. dos/timeline.py +1027 -0
  160. dos/tokens.py +485 -0
  161. dos/tool_stream.py +393 -0
  162. dos/tool_stream_eval.py +226 -0
  163. dos/trace.py +524 -0
  164. dos/verdict.py +140 -0
  165. dos/verdict_cli.py +189 -0
  166. dos/verdict_journal.py +497 -0
  167. dos/verdict_rollup.py +217 -0
  168. dos/verdicts.py +181 -0
  169. dos/wedge_reason.py +282 -0
  170. dos_kernel-0.22.0.dist-info/METADATA +859 -0
  171. dos_kernel-0.22.0.dist-info/RECORD +178 -0
  172. dos_kernel-0.22.0.dist-info/WHEEL +5 -0
  173. dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
  174. dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
  175. dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
  176. dos_mcp/__init__.py +52 -0
  177. dos_mcp/py.typed +2 -0
  178. dos_mcp/server.py +779 -0
dos/pretool_sensor.py ADDED
@@ -0,0 +1,490 @@
1
+ """pretool-sensor — the boundary I/O for the PRE moment of a tool call (docs/191).
2
+
3
+ > **The PRE moment is the unique cell where a DOS verdict is both SOUND and backed
4
+ > by real DENY-power.** `dos hook posttool` (the BOUNDARY moment) can only ADD
5
+ > context — the call already fired. `dos hook stop` (the STOP moment) can only emit
6
+ > a host-honored `{ok:false}`. Only a `PreToolUse` hook can return a
7
+ > `permissionDecision: deny` the runtime honors BEFORE the tool runs (the docs/126
8
+ > mediated-write moment). This module is the boundary adapter that reads a CC
9
+ > `PreToolUse` event, runs the already-shipped PURE kernel verdicts that are sound at
10
+ > PRE, and emits the exact CC dialect — the `posttool_sensor` sibling, one moment
11
+ > earlier on the tool-call timeline.**
12
+
13
+ The evidence-locus asymmetry (docs/191 §0) is the whole reason this module exists and
14
+ is constrained the way it is. At PRE the only bytes that exist for THIS call are
15
+ agent-authored (`tool_name` / `tool_input`); this call's env-authored `result_digest`
16
+ does NOT exist yet (that is what BOUNDARY adds). So a PRE verdict may use ONLY:
17
+
18
+ * the proposed call's own path/tree (`SelfModifyPredicate`, `DisjointnessPredicate` —
19
+ request-absolute / lease-relative admission, sound on the proposed tree alone);
20
+ * the agent's args checked against the corpus of PRIOR env results
21
+ (`arg_provenance.classify_call` — the cross-moment join: prior RESULTS are
22
+ env-authored, available at PRE even though THIS result is not).
23
+
24
+ `tool_stream` REPEATING and `terminal_error` are UNSOUND here — they need this call's
25
+ env `result_digest`, which does not exist until BOUNDARY. They bind at POST only. This
26
+ module never computes them.
27
+
28
+ Two rungs, two safe-failure directions (docs/191 §3 — keep them rigorously apart)
29
+ =================================================================================
30
+
31
+ * **Rung A — structural admission** (auto-deny-safe). `admission.run_predicates`
32
+ over the built-in conjunction (`DisjointnessPredicate`, `SelfModifyPredicate` — the
33
+ ONLY two built-ins; there is no "dangerous-exec" class) is conjunctive-only +
34
+ fail-CLOSED-to-REFUSE. A buggy predicate can only OVER-refuse, and an admission
35
+ over-refusal is operator-visible + `--force`-overridable — an admission gate, NOT a
36
+ mid-plan derail, so it carries no docs/143 −9 pp exposure. A Rung-A refusal with a
37
+ structural reason becomes a `permissionDecision: deny` directly.
38
+
39
+ * **Rung B — behavioral provenance** (confidence-gated, fail-to-OBSERVE). The
40
+ provenance verdict is routed `classify_call → intervention.choose_intervention →
41
+ enforce.run_handler`. `choose_intervention` clamps into `[floor, ceiling]` with the
42
+ DEFAULT `ceiling=BLOCK`, so DEFER (the turn-spending rung) is structurally
43
+ unreachable. `run_handler` is fail-to-OBSERVE + no-escalation: a handler that raises
44
+ / returns a non-`EffectProposal` → OBSERVE (no deny), and a handler can never
45
+ propose harder than the kernel's confidence-gated rung. So a handler bug CANNOT
46
+ manufacture a deny.
47
+
48
+ These coexist in one hook with NO contradiction (the docs/191 §5 correction): admission
49
+ fails CLOSED (cheap, visible over-refusal) while the behavioral path fails toward
50
+ WARN/OBSERVE (the expensive −9 pp direction is the one avoided). The two are selected by
51
+ which seam produced the verdict.
52
+
53
+ Why it stays a PDP, not a PEP (docs/191 §4)
54
+ ===========================================
55
+
56
+ A PRE deny is an `EffectProposal{dispatch_call=False}` — a PROPOSAL the kernel computes.
57
+ The CC runtime is the PEP that consumes `permissionDecision: deny` and actually withholds
58
+ the call. The default handler is `ObserveHandler`, which proposes OBSERVE on everything →
59
+ ZERO deny until a driver wires a ruling handler. So a default install emits no deny: DOS
60
+ is PDP-only by construction. The CC `PreToolUse` schema also offers `updatedInput` (rewrite
61
+ the agent's args) — this module DELIBERATELY never emits it: minting corrective bytes FOR
62
+ the agent would violate the byte-author invariant (docs/138). PRE stays
63
+ deny / passthrough / additionalContext only.
64
+
65
+ ⚓ Kernel discipline (the litmus): a PURE verdict-adapter — imports only sibling kernel
66
+ modules (`admission`, `self_modify`, `arg_provenance`, `intervention`, `enforce`,
67
+ `lane_journal`, `config`), names no host beyond the `PreToolUse` JSON shape, resolves
68
+ every path via `SubstrateConfig`, takes no lease, carries no policy of its own (the
69
+ thresholds live in `ProvenancePolicy` / the `InterventionLadder` / `StreamPolicy`).
70
+ """
71
+
72
+ from __future__ import annotations
73
+
74
+ import json
75
+ import sys
76
+ from typing import Optional
77
+
78
+ try:
79
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
80
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
81
+ except Exception:
82
+ pass
83
+
84
+ from dos import config as _config
85
+
86
+ # The CC PreToolUse result keys we must NOT see — their ABSENCE is the structural marker
87
+ # that distinguishes a PreToolUse event from a PostToolUse one (docs/191 §6). A PRE event
88
+ # carries no tool RESULT; if one of these is present the event is mis-routed (a PostToolUse
89
+ # event sent to the pre hook), and we decline to treat agent-unseen result bytes as PRE
90
+ # evidence. The same dual-key the posttool sensor reads on the other side.
91
+ _RESULT_KEYS = ("tool_response", "tool_output")
92
+
93
+ # The tools whose `tool_input` names a filesystem path we can turn into an admission tree.
94
+ # Conservative + host-shaped: a host with different tool names declares its own mapping in a
95
+ # driver. The kernel knows only the generic CC edit/write tools. A Bash command is parsed
96
+ # best-effort (see `_tree_from_event`); an unrecognized mutating tool yields an UNKNOWN tree
97
+ # (empty), which the SELF_MODIFY rung treats as unknown blast radius (the safe direction).
98
+ _PATH_ARG_KEYS = ("file_path", "path", "notebook_path")
99
+
100
+ # Read-only tools never take an admission tree (reads are how provenance ENTERS the corpus,
101
+ # docs/191 §2) — an empty tree admits. A tool not in either set is treated as potentially
102
+ # mutating with an unknown tree (conservative).
103
+ _READ_ONLY_TOOLS = frozenset(
104
+ {"Read", "Grep", "Glob", "LS", "NotebookRead", "WebFetch", "WebSearch"}
105
+ )
106
+ _WRITE_TOOLS = frozenset({"Write", "Edit", "MultiEdit", "NotebookEdit"})
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # PURE adapters — a PreToolUse event in, the pure kernel inputs out (no I/O).
111
+ # ---------------------------------------------------------------------------
112
+ def is_pre_event(event: dict) -> bool:
113
+ """True iff this looks like a PreToolUse event we should act on. PURE.
114
+
115
+ The structural PRE marker (docs/191 §6): a `tool_name` present AND no tool RESULT key
116
+ (`tool_response`/`tool_output`). A PostToolUse event mis-routed to the pre hook carries
117
+ a result key — we decline it (return False) so we never treat agent-unseen result bytes
118
+ as PRE evidence, and the caller emits nothing (passthrough). A `hook_event_name` of
119
+ `PreToolUse`, when present, is honored too — but its absence is not disqualifying (older
120
+ builds omit it); the result-key absence is the load-bearing test.
121
+ """
122
+ if not isinstance(event, dict):
123
+ return False
124
+ tool_name = event.get("tool_name")
125
+ if not (isinstance(tool_name, str) and tool_name):
126
+ return False
127
+ name = event.get("hook_event_name")
128
+ if isinstance(name, str) and name and name != "PreToolUse":
129
+ return False
130
+ for k in _RESULT_KEYS:
131
+ if event.get(k) is not None:
132
+ return False # a result is present → this is a BOUNDARY event, not PRE
133
+ return True
134
+
135
+
136
+ def _tree_from_event(event: dict) -> tuple[tuple[str, ...], bool]:
137
+ """The admission tree for the proposed call + whether the tree is KNOWN. PURE.
138
+
139
+ Returns `(tree, known)`:
140
+ * a read-only tool → `((), True)` (empty tree, known-empty → admits; reads are how
141
+ provenance enters the corpus, never a self-modify hazard).
142
+ * a write/edit tool with a path arg → `((path,), True)`.
143
+ * a write/edit tool with NO usable path, or an unrecognized (potentially mutating)
144
+ tool → `((), False)` — an UNKNOWN tree. The caller treats unknown-blast-radius
145
+ conservatively at the SELF_MODIFY rung (docs/191 §6: a missed self-modify is the
146
+ dangerous direction; an un-parseable mutating tree must not silently admit).
147
+
148
+ Tree extraction from `tool_input` is intentionally lossy and host-shaped — the lane
149
+ arbiter historically got trees from the dispatch layer, not from a tool-arg parse. The
150
+ kernel handles only the generic CC edit/write tools + a best-effort Bash path scrape; a
151
+ host with other tools declares its own mapping in a driver.
152
+ """
153
+ tool_name = event.get("tool_name")
154
+ if not isinstance(tool_name, str):
155
+ return (), False
156
+ if tool_name in _READ_ONLY_TOOLS:
157
+ return (), True # known-empty: a read takes no tree, admits
158
+ tool_input = event.get("tool_input")
159
+ if not isinstance(tool_input, dict):
160
+ tool_input = {}
161
+ # A direct path arg (Write/Edit/NotebookEdit and the like).
162
+ for k in _PATH_ARG_KEYS:
163
+ v = tool_input.get(k)
164
+ if isinstance(v, str) and v.strip():
165
+ return (_repo_relative(v.strip(), event),), True
166
+ # Bash: best-effort scrape of path-shaped tokens from the command. Conservative — if we
167
+ # find nothing path-shaped we return UNKNOWN (not empty-known), so a mutating command we
168
+ # cannot parse is treated as unknown blast radius, never silently admitted.
169
+ if tool_name == "Bash":
170
+ cmd = tool_input.get("command")
171
+ if isinstance(cmd, str) and cmd.strip():
172
+ paths = _paths_from_command(cmd)
173
+ if paths:
174
+ return tuple(_repo_relative(p, event) for p in paths), True
175
+ return (), False # unknown command footprint → unknown tree
176
+ if tool_name in _WRITE_TOOLS:
177
+ return (), False # a write tool with no resolvable path → unknown, conservative
178
+ # An unrecognized tool: could be a mutating MCP tool. Unknown tree (conservative) — the
179
+ # SELF_MODIFY rung sees unknown blast radius; but since the tree is empty AND we cannot
180
+ # name a runtime-file collision, this degrades to admit at Rung A (no false deny) while
181
+ # Rung B (provenance) still applies to its args. The honest middle: we never invent a
182
+ # collision we cannot prove, and we never claim a read is safe when we cannot tell.
183
+ return (), False
184
+
185
+
186
+ def _repo_relative(path: str, event: dict) -> str:
187
+ """Best-effort repo-relative POSIX form of a path (the shape a lane tree carries). PURE.
188
+
189
+ A lane tree is repo-relative POSIX (e.g. `src/dos/arbiter.py`). We normalize separators
190
+ and, when the path is under the event's `cwd`, strip that prefix. This is best-effort:
191
+ when we cannot relativize (an absolute path outside cwd) we return the POSIX-normalized
192
+ absolute form, which the SELF_MODIFY runtime-file compare will simply not match (the
193
+ safe direction — an unrelatable path is not claimed to be a kernel file).
194
+ """
195
+ p = path.replace("\\", "/")
196
+ cwd = event.get("cwd")
197
+ if isinstance(cwd, str) and cwd:
198
+ c = cwd.replace("\\", "/").rstrip("/")
199
+ if p.startswith(c + "/"):
200
+ return p[len(c) + 1 :]
201
+ return p.lstrip("/")
202
+
203
+
204
+ def _paths_from_command(cmd: str) -> tuple[str, ...]:
205
+ """Best-effort path-shaped tokens from a Bash command string. PURE.
206
+
207
+ NOT a shell parser — a heuristic scrape for tokens that look like file paths (contain a
208
+ `/` and a recognizable suffix, or name a known runtime file). Used only to give the
209
+ SELF_MODIFY rung a chance to fire on `echo x > src/dos/arbiter.py`. Returns `()` when
210
+ nothing path-shaped is found, which `_tree_from_event` maps to an UNKNOWN tree (the
211
+ conservative branch). Deliberately under-extracts: a missed path → unknown tree →
212
+ conservative, never a fabricated collision.
213
+ """
214
+ out: list[str] = []
215
+ for raw in cmd.replace(";", " ").replace("|", " ").replace("&", " ").split():
216
+ tok = raw.strip("\"'()<>")
217
+ if "/" in tok and not tok.startswith("-") and "." in tok.rsplit("/", 1)[-1]:
218
+ out.append(tok)
219
+ return tuple(dict.fromkeys(out)) # de-dup, preserve order
220
+
221
+
222
+ def is_mutating_tool(event: dict) -> bool:
223
+ """Whether the proposed call mutates state — the `ToolCall.is_mutating` flag. PURE.
224
+
225
+ FAIL-OPEN (docs/191 §3, the `arg_provenance` posture): when unsure, treat as a READ
226
+ (`is_mutating=False`), which short-circuits the provenance fold to ABSTAIN-all. Under-
227
+ gating is the feasible-task-safe direction — a false gate risks a real regression while
228
+ a missed gate just degrades to baseline. A tool explicitly in the read-only set is a
229
+ read; a write tool / Bash is mutating; anything else is conservatively mutating ONLY for
230
+ the provenance check (Rung B fails to OBSERVE, so a wrong guess there cannot deny).
231
+ """
232
+ tool_name = event.get("tool_name")
233
+ if not isinstance(tool_name, str):
234
+ return False
235
+ if tool_name in _READ_ONLY_TOOLS:
236
+ return False
237
+ return True
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # PURE dialect renderers — a verdict in, the exact CC PreToolUse dialect out (no I/O).
242
+ # ---------------------------------------------------------------------------
243
+ def deny_payload(reason: str, *, additional_context: str = "") -> dict:
244
+ """The CC PreToolUse DENY dialect — `permissionDecision: deny`. PURE.
245
+
246
+ The one envelope real Claude Code honors to block a tool BEFORE it runs (verified
247
+ against the CC v2.1.88 source: `permissionBehaviorSchema = z.enum(['allow','deny',
248
+ 'ask'])`; `deny` sets `result.permissionBehavior='deny'` and skips the tool). Field
249
+ names are case-sensitive and exact, the same load-bearing dialect-exactness the
250
+ posttool sensor's `additionalContext` envelope depends on (emit the wrong shape and the
251
+ hook is a SILENT no-op, the old `dos hook stop` lesson). NEVER emits `updatedInput`
252
+ (that would mint corrective bytes for the agent — a byte-author violation, docs/191 §4).
253
+ """
254
+ out = {
255
+ "hookSpecificOutput": {
256
+ "hookEventName": "PreToolUse",
257
+ "permissionDecision": "deny",
258
+ "permissionDecisionReason": reason,
259
+ }
260
+ }
261
+ if additional_context:
262
+ out["hookSpecificOutput"]["additionalContext"] = additional_context
263
+ return out
264
+
265
+
266
+ def warn_payload(text: str) -> dict:
267
+ """The CC PreToolUse WARN dialect — `additionalContext` ONLY, no `permissionDecision`. PURE.
268
+
269
+ A WARN does NOT deny: it omits `permissionDecision` entirely (so CC's normal permission
270
+ flow proceeds — passthrough) and only ADDS a re-surfaced fact to the next turn. This is
271
+ the turn-preserving soft rung: the agent gets the corrective OBSERVATION without losing
272
+ its turn (docs/191 §3, the WARN-and-pass resolution for a LOW-confidence / composite
273
+ mint).
274
+ """
275
+ return {
276
+ "hookSpecificOutput": {
277
+ "hookEventName": "PreToolUse",
278
+ "additionalContext": text,
279
+ }
280
+ }
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # The impure half — gather PRE evidence at the boundary (the cross-moment join +
285
+ # the live-lease read), then run the pure kernel verdicts. All I/O is HERE, never
286
+ # inside a verdict (the `liveness`/`posttool_sensor` boundary discipline).
287
+ # ---------------------------------------------------------------------------
288
+ def live_leases_for(cfg: "_config.SubstrateConfig") -> list[dict]:
289
+ """Replay the workspace lane journal into the live leases a PRE admission check sees.
290
+
291
+ Boundary I/O (the `lane_journal` WAL read), handed to the pure `run_predicates`. Any
292
+ failure (no journal, unreadable, replay error) → `[]` (no leases), which makes
293
+ DisjointnessPredicate admit and leaves SelfModifyPredicate (request-absolute) still
294
+ firing — the same idle-repo behavior `run_predicates` documents. Fail-safe: a journal
295
+ read fault never denies a real call (it degrades to "no leases", the safe direction for
296
+ the COLLISION rung; SELF_MODIFY is unaffected because it answers from the request).
297
+ """
298
+ try:
299
+ from dos import lane_lease
300
+ # `expire_dead=True`: the PRE-admission gate is a CONTENTION read — a crashed
301
+ # worker's un-RELEASEd ACQUIRE (a phantom orphan whose TTL aged out or whose
302
+ # holder PID is confidently gone on this host) must NOT silently revoke the
303
+ # interactive session's Read/Edit on every tool call (docs/281 Defect 1).
304
+ # The live set self-heals here instead of waiting for an external SCAVENGE;
305
+ # only the provably-dead are dropped, so a genuinely-live lane still gates.
306
+ return lane_lease.live_leases(cfg, expire_dead=True)
307
+ except Exception:
308
+ return []
309
+
310
+
311
+ def prior_results(session_id: str, cfg: "_config.SubstrateConfig"):
312
+ """The env-authored corpus of PRIOR tool results — the cross-moment join (docs/191 §2).
313
+
314
+ The load-bearing PRE-soundness move: this call's result does not exist, but EARLIER
315
+ calls' results do, and they are env-authored. We read them from the SAME accumulating
316
+ `posttool_sensor` stream the BOUNDARY hook writes — so the PRE provenance check sees
317
+ every prior RESULT digest, tagged `TOOL_RESULT` (env source — `CorpusSource` has no
318
+ `AGENT_AUTHORED` member, so a minted id can never launder itself into the corpus).
319
+
320
+ NOTE the honest limit (docs/191 §8 coupling tension): the posttool stream stores DIGESTS
321
+ of prior results, not their raw bytes, so the provenance corpus here is built from the
322
+ result digests + tool names the stream retained. A missing/stale stream degrades to an
323
+ EMPTY corpus, which `classify_call` reads as "cannot prove mintage → ABSTAIN-all" — the
324
+ safe direction (no false deny), at the cost of PRE coverage only as good as the POST
325
+ stream that feeds it. Any failure → empty corpus (fail-safe).
326
+ """
327
+ from dos.arg_provenance import EnvBlob, PriorResults, CorpusSource
328
+ blobs: list = []
329
+ try:
330
+ from dos import posttool_sensor as _pts
331
+ stream = _pts.read_stream(session_id, cfg)
332
+ for step in stream.steps:
333
+ # The env-authored evidence available from the stream: the prior result digest
334
+ # and the tool name. We fold each prior RESULT digest into the corpus as a
335
+ # TOOL_RESULT blob. (A future phase that retains raw result bytes would carry
336
+ # them here verbatim; v1 carries the digest the stream kept.)
337
+ if step.result_digest:
338
+ blobs.append(EnvBlob(text=str(step.result_digest), source=CorpusSource.TOOL_RESULT))
339
+ except Exception:
340
+ return PriorResults(())
341
+ # The task text, when the event/host surfaced it, would be a TASK_TEXT blob — not
342
+ # available from the PreToolUse event in v1, so the corpus is prior RESULTS only.
343
+ return PriorResults(tuple(blobs))
344
+
345
+
346
+ def toolcall_from_event(event: dict):
347
+ """Build the `arg_provenance.ToolCall` for the proposed call. PURE-ish (no I/O).
348
+
349
+ Flattens the agent-authored `tool_input` dict into `ToolArg`s (the value the model
350
+ emitted per arg) and sets `is_mutating` via the fail-open classifier. Returns None for a
351
+ non-mutating call (a read short-circuits provenance to ABSTAIN-all) or a malformed event.
352
+ """
353
+ from dos.arg_provenance import ToolArg, ToolCall
354
+ tool_name = event.get("tool_name")
355
+ if not (isinstance(tool_name, str) and tool_name):
356
+ return None
357
+ if not is_mutating_tool(event):
358
+ return None # reads are never gated — short-circuit
359
+ tool_input = event.get("tool_input")
360
+ if not isinstance(tool_input, dict):
361
+ tool_input = {}
362
+ args = tuple(ToolArg(name=str(k), value=v) for k, v in tool_input.items())
363
+ return ToolCall(tool_name=str(tool_name), args=args, is_mutating=True)
364
+
365
+
366
+ # ---------------------------------------------------------------------------
367
+ # The composed PRE decision — the two rungs, in order. Returns the CC dialect dict
368
+ # to emit (or None for passthrough) PLUS the structured outcome for the journal.
369
+ # ---------------------------------------------------------------------------
370
+ def decide(
371
+ event: dict,
372
+ cfg: "_config.SubstrateConfig",
373
+ *,
374
+ handler_name: str = "observe",
375
+ ) -> tuple[Optional[dict], dict]:
376
+ """Run the PRE division on one event → `(dialect_or_None, outcome_record)`.
377
+
378
+ `dialect_or_None` is the CC PreToolUse JSON to print (deny / warn) or None (passthrough —
379
+ emit nothing). `outcome_record` is the structured forensic body the CLI journals on a
380
+ non-passthrough outcome (the `OP_ENFORCE` evidence, docs/189 §C4).
381
+
382
+ Rung A (admission) runs first: a structural refusal denies immediately. Rung B
383
+ (provenance → intervention → enforce.run_handler) runs only if Rung A admitted. The
384
+ DEFAULT `handler_name="observe"` is the PDP-only floor — `ObserveHandler` proposes
385
+ OBSERVE on everything, so a default install emits ZERO deny from Rung B (a deny there
386
+ requires a wired ruling handler). All of `decide`'s own faults fail toward passthrough.
387
+ """
388
+ # ---- Rung A: structural admission (auto-deny-safe, fail-CLOSED-to-refuse) ----
389
+ from dos import admission
390
+ tree, tree_known = _tree_from_event(event)
391
+ request = admission.AdmissionRequest(
392
+ lane=str(event.get("tool_name") or "tool"),
393
+ kind="tool-call",
394
+ tree=tree,
395
+ )
396
+ leases = live_leases_for(cfg)
397
+ predicates = admission.active_predicates(config=cfg)
398
+ averdict = admission.run_predicates(predicates, request, leases, cfg)
399
+ if not averdict.admitted:
400
+ # A non-admit is one of TWO very different things, and only one is deny-safe at PRE:
401
+ #
402
+ # (a) a STRUCTURAL refusal we can PROVE — a typed `reason_class` (SELF_MODIFY), or a
403
+ # region collision on a KNOWN **and non-empty** tree (`tree_known and tree` — a
404
+ # parseable footprint that really overlaps a held lease). This is the operator-
405
+ # visible, --force-overridable admission gate; a pre-dispatch deny here strictly
406
+ # dominates a post-hoc WARN (docs/191 §3). → deny.
407
+ #
408
+ # (b) a CONTENTION-only refusal we CANNOT prove collides — an UNKNOWN tree
409
+ # (`tree_known=False`, an un-parseable mutating footprint) OR a KNOWN-but-EMPTY
410
+ # tree (a read: `_tree_from_event` → `((), True)`) that got refused only because the
411
+ # requested lane was contended ("no lane available" / the empty-requested-tree
412
+ # "unknown blast radius" rule), with NO structural `reason_class`. In neither case
413
+ # can we show the call actually collides — a read touches NOTHING, and a pathless
414
+ # write footprint is unknown — so it may be an innocent read / `git status` / `npm
415
+ # test` running while an UNRELATED lane is leased. Denying it is the docs/143 −9 pp
416
+ # spurious-disruption mistake (and the PreToolUse ABI gives the agent no --force
417
+ # escape — a wrong deny just fails the turn). → WARN-and-pass (additionalContext
418
+ # only, no permissionDecision), the turn-preserving safe direction.
419
+ #
420
+ # The load-bearing correction (FQ-532 Defect 3): `tree_known` ALONE is NOT proof of a
421
+ # collision — a read has a KNOWN but EMPTY tree, and the old `reason_class or tree_known`
422
+ # gate escalated that contention-only refusal to a hard DENY for every Read/Edit while a
423
+ # Bash (unknown tree) only WARNed (the "route-through-Bash" asymmetry). Requiring a
424
+ # NON-EMPTY known tree keeps a contention-only refusal ADVISORY regardless of tree_known,
425
+ # and only a parseable footprint that really overlaps denies — the same "never invent a
426
+ # collision we cannot prove" line `_tree_from_event` already draws for the empty-tree case.
427
+ reason = averdict.reason or "DOS admission refused this call (no lane available)."
428
+ provable = bool(averdict.reason_class) or (tree_known and bool(tree))
429
+ if provable:
430
+ outcome = {
431
+ "rung": "admission",
432
+ "decision": "deny",
433
+ "reason_class": averdict.reason_class or "",
434
+ "reason": reason,
435
+ "tree_known": tree_known,
436
+ }
437
+ return deny_payload(f"DOS PRE-admission: {reason}"), outcome
438
+ outcome = {
439
+ "rung": "admission",
440
+ "decision": "warn",
441
+ "reason_class": averdict.reason_class or "",
442
+ "reason": reason,
443
+ "tree_known": tree_known,
444
+ }
445
+ return (
446
+ warn_payload(
447
+ f"DOS PRE-admission (advisory): {reason} This call's footprint does not prove a "
448
+ f"collision (a read touches nothing; an unresolved write footprint is unknown), "
449
+ f"so DOS cannot prove it collides — proceeding, but "
450
+ f"if this call mutates shared state, scope it to a declared path/lane."
451
+ ),
452
+ outcome,
453
+ )
454
+
455
+ # ---- Rung B: behavioral provenance (confidence-gated, fail-to-OBSERVE) ----
456
+ call = toolcall_from_event(event)
457
+ if call is None:
458
+ return None, {"rung": "none", "decision": "passthrough", "reason": "read / non-mutating call"}
459
+ from dos import arg_provenance, intervention, enforce
460
+ prior = prior_results(str(event.get("session_id") or ""), cfg)
461
+ pverdict = arg_provenance.classify_call(call, prior, arg_provenance.DEFAULT_POLICY)
462
+ decision = intervention.choose_intervention(
463
+ pverdict, intervention.DEFAULT_POLICY, cfg.interventions
464
+ )
465
+ handler = enforce.resolve_handler(handler_name)
466
+ proposal = enforce.run_handler(handler, decision, cfg)
467
+ base = {
468
+ "rung": "provenance",
469
+ "intervention": proposal.intervention.value,
470
+ "confidence": decision.confidence.value,
471
+ "handler": proposal.handler,
472
+ "unsupported": list(decision.unsupported),
473
+ }
474
+ if proposal.withholds_call:
475
+ # A turn-preserving BLOCK → deny, with the synthetic corrective surfaced in the
476
+ # reason (names the unresolved arg by NAME + component TOKENS only — the
477
+ # anti-laundering shape; never echoes the minted id value).
478
+ synth = proposal.synthetic_result or intervention.synthetic_corrective_result(
479
+ pverdict, call.tool_name
480
+ )
481
+ ctx = json.dumps(synth, sort_keys=True, ensure_ascii=False)
482
+ reason = proposal.note or decision.reason or "an id argument was minted, not resolved."
483
+ return deny_payload(f"DOS PRE-provenance: {reason}", additional_context=ctx), {
484
+ **base, "decision": "deny",
485
+ }
486
+ if proposal.intervention is intervention.Intervention.WARN and proposal.note:
487
+ # WARN-and-pass: additionalContext only, no permissionDecision (passthrough).
488
+ return warn_payload(f"DOS PRE-provenance: {proposal.note}"), {**base, "decision": "warn"}
489
+ # OBSERVE (or a WARN with nothing to surface) → emit nothing.
490
+ return None, {**base, "decision": "passthrough"}
dos/proc_delta.py ADDED
@@ -0,0 +1,181 @@
1
+ """proc-delta — the OS process-liveness rung, one shared boundary reader.
2
+
3
+ docs/95 — the proc-liveness rung the liveness verdict was missing.
4
+
5
+ `dos.liveness` decides ADVANCING / SPINNING / STALLED from a forward-delta (git
6
+ commits, lane-journal events) and a heartbeat age. But the alive/dead half of
7
+ that verdict — SPINNING ("alive, narrating, not moving") vs STALLED ("dead/hung")
8
+ — rests ENTIRELY on a caller-supplied heartbeat. A heartbeat is forgeable: a
9
+ crashed agent whose last act wrote a fresh `heartbeat_at` (or whose `.lease-
10
+ liveness` mtime a wrapper keeps touching) reads SPINNING when the process is gone.
11
+ That is the exact gap docs/95 §3 names — the verdict trusts a self-reported beat
12
+ where it could instead ask the OS process table, which the dead process cannot
13
+ keep fresh.
14
+
15
+ This module is that OS rung: given a pid (and the host it was recorded on), it
16
+ asks the kernel "is this process actually alive right now?" — the one liveness
17
+ signal an agent cannot fabricate after it dies. Like `git_delta`/`journal_delta`
18
+ it is **boundary I/O, not a pure verdict**: the probe happens HERE, at the caller
19
+ boundary, and the already-resolved `alive: Optional[bool]` is handed to the pure
20
+ `liveness.classify` as one more piece of frozen evidence (the arbiter discipline —
21
+ no I/O inside the verdict).
22
+
23
+ The design rules (docs/95), each load-bearing:
24
+
25
+ * **Never fabricate `True`.** Every failure mode — a foreign host, no pid, an
26
+ unsupported platform, a PermissionError, ANY OSError — degrades to
27
+ `alive=None` ("could not tell"), NEVER to `alive=True`. A None corroborates
28
+ nothing and demotes nothing; only a *confident* `False` (the process is
29
+ provably gone) is allowed to flip a verdict. This is the fail-safe direction:
30
+ an unforgeable signal that can only ever make the verdict MORE skeptical, the
31
+ same shape as the overlap floor and the judge's fail-to-abstain.
32
+ * **Foreign-host blindness.** A pid is only meaningful on the host that minted
33
+ it; pid 4242 on `boxA` says nothing about `boxB`. If the recorded `host_id`
34
+ is not `this_host`, the probe returns `alive=None` — it refuses to read its
35
+ own process table as if it were the other host's (the cross-host false-True
36
+ docs/95 explicitly forbids).
37
+ * **stdlib + ctypes only.** No psutil, no new dependency — the kernel's import
38
+ set stays PyYAML-only (the CLAUDE.md litmus). POSIX uses `os.kill(pid, 0)`;
39
+ win32 uses `OpenProcess` via `ctypes` and distinguishes alive from exited.
40
+ * **Demote-only at the consumer.** This module just reports; `liveness.classify`
41
+ is where a `False` flips SPINNING→STALLED and a True/None never promotes
42
+ dead→alive. The kernel that doesn't believe the agents also doesn't believe a
43
+ bare "process looks up" as proof of *progress* — only as proof of *life*.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import os
49
+ import sys
50
+ from dataclasses import dataclass
51
+ from typing import Optional
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ProcLiveness:
56
+ """The result of one OS process probe — `alive` plus a one-line `detail`.
57
+
58
+ `alive` is THREE-valued on purpose:
59
+ * True — the process is confidently up (the OS confirms it exists/running).
60
+ * False — the process is confidently gone (the OS confirms no such live pid
61
+ on this host). The ONLY value allowed to demote a verdict.
62
+ * None — could not tell (foreign host, no pid, unsupported platform, a
63
+ permission/OS error). Corroborates nothing, demotes nothing.
64
+
65
+ `detail` is an operator-facing one-liner for `--output json` legibility — the
66
+ same "legible distrust" the liveness verdict's reason carries.
67
+ """
68
+
69
+ alive: Optional[bool]
70
+ detail: str
71
+
72
+
73
+ def _probe_posix(pid: int) -> ProcLiveness:
74
+ """`os.kill(pid, 0)` — signal 0 tests existence + permission without delivering.
75
+
76
+ Returns True if the process exists (or exists-but-not-ours, ESRCH vs EPERM),
77
+ False on ESRCH (no such process), None on any other OSError (we couldn't tell).
78
+ """
79
+ try:
80
+ os.kill(pid, 0)
81
+ return ProcLiveness(True, f"pid {pid} is alive (posix kill 0 succeeded)")
82
+ except ProcessLookupError:
83
+ return ProcLiveness(False, f"pid {pid} is gone (posix ESRCH — no such process)")
84
+ except PermissionError:
85
+ # The process EXISTS but is owned by another user — existence is confirmed,
86
+ # which is what liveness needs (it asks "alive?", not "ours?").
87
+ return ProcLiveness(True, f"pid {pid} is alive (posix EPERM — exists, not ours)")
88
+ except OSError as e: # any other errno — we genuinely cannot tell
89
+ return ProcLiveness(None, f"pid {pid} undetermined (posix OSError {e})")
90
+
91
+
92
+ # win32 OpenProcess access right + the "still running" sentinel.
93
+ _PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
94
+ _STILL_ACTIVE = 259 # GetExitCodeProcess returns this while the process runs
95
+
96
+
97
+ def _probe_win32(pid: int) -> ProcLiveness:
98
+ """`OpenProcess` + `GetExitCodeProcess` via ctypes — alive iff exit code is STILL_ACTIVE.
99
+
100
+ A successful OpenProcess alone is NOT proof of life: Windows keeps a process
101
+ object openable after exit while a handle lingers, so a freshly-exited pid can
102
+ still open. We must read the exit code and confirm it is STILL_ACTIVE (259).
103
+ Any failure to determine → None (never a fabricated True).
104
+ """
105
+ try:
106
+ import ctypes
107
+ from ctypes import wintypes
108
+ except Exception as e: # ctypes unavailable — cannot tell
109
+ return ProcLiveness(None, f"pid {pid} undetermined (ctypes unavailable: {e})")
110
+ try:
111
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
112
+ kernel32.OpenProcess.restype = wintypes.HANDLE
113
+ kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
114
+ handle = kernel32.OpenProcess(
115
+ _PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
116
+ if not handle:
117
+ err = ctypes.get_last_error()
118
+ # ERROR_INVALID_PARAMETER (87) on a non-existent pid == confidently gone.
119
+ # ERROR_ACCESS_DENIED (5) == the process EXISTS but we can't open it →
120
+ # existence confirmed (alive), same as POSIX EPERM.
121
+ if err == 5:
122
+ return ProcLiveness(True, f"pid {pid} is alive (win32 ACCESS_DENIED — exists)")
123
+ if err == 87:
124
+ return ProcLiveness(False, f"pid {pid} is gone (win32 INVALID_PARAMETER)")
125
+ return ProcLiveness(None, f"pid {pid} undetermined (win32 OpenProcess err {err})")
126
+ try:
127
+ exit_code = wintypes.DWORD()
128
+ ok = kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))
129
+ if not ok:
130
+ return ProcLiveness(None, f"pid {pid} undetermined (win32 GetExitCodeProcess failed)")
131
+ if exit_code.value == _STILL_ACTIVE:
132
+ return ProcLiveness(True, f"pid {pid} is alive (win32 STILL_ACTIVE)")
133
+ return ProcLiveness(
134
+ False, f"pid {pid} is gone (win32 exit code {exit_code.value})")
135
+ finally:
136
+ kernel32.CloseHandle(handle)
137
+ except Exception as e: # any ctypes/OS failure — we cannot tell
138
+ return ProcLiveness(None, f"pid {pid} undetermined (win32 probe error: {e})")
139
+
140
+
141
+ def probe(
142
+ pid: Optional[int],
143
+ *,
144
+ host_id: str = "",
145
+ this_host: str = "",
146
+ ) -> ProcLiveness:
147
+ """Is `pid` (recorded on `host_id`) alive RIGHT NOW on `this_host`? Never raises.
148
+
149
+ The single boundary reader `dos.liveness`'s evidence-gather calls to fill
150
+ `ProgressEvidence.process_alive`. Resolves to:
151
+
152
+ * None — no pid (None / ≤0 sentinel), a foreign host (`host_id` set and ≠
153
+ `this_host`), an unsupported platform, or any probe error. The
154
+ "could not tell" value: it neither promotes nor demotes a verdict.
155
+ * True — the OS confirms a live process for `pid` on this host.
156
+ * False — the OS confirms no such live process on this host (the one value
157
+ that may demote SPINNING→STALLED downstream).
158
+
159
+ `host_id` is the host the lease/run recorded the pid on; `this_host` is where
160
+ we are probing. Both default to "" so a caller that does not track hosts (a
161
+ single-box workspace) gets a pure pid probe — the foreign-host guard only
162
+ fires when BOTH are set and differ, never blindly refusing a host-less pid.
163
+ """
164
+ if pid is None or pid <= 0:
165
+ # ≤0 is the lease layer's "no real pid" sentinel (TTL-only liveness) — there
166
+ # is nothing to probe, so we cannot tell (and must not pretend gone=False,
167
+ # which would demote a TTL-only lease the heartbeat says is fine).
168
+ return ProcLiveness(None, f"no probeable pid (pid={pid!r}) — TTL-only liveness")
169
+
170
+ if host_id and this_host and host_id != this_host:
171
+ return ProcLiveness(
172
+ None,
173
+ f"pid {pid} was recorded on host {host_id!r}, probing from "
174
+ f"{this_host!r} — foreign host, cannot tell",
175
+ )
176
+
177
+ if sys.platform.startswith("win"):
178
+ return _probe_win32(pid)
179
+ if os.name == "posix":
180
+ return _probe_posix(pid)
181
+ return ProcLiveness(None, f"pid {pid} on unsupported platform {sys.platform!r} — cannot tell")