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.
- dos/__init__.py +261 -0
- dos/_bin/dos-hook.exe +0 -0
- dos/_filelock.py +255 -0
- dos/_job_policy.py +97 -0
- dos/_tree.py +145 -0
- dos/admission.py +433 -0
- dos/answer_shape.py +299 -0
- dos/arbiter.py +859 -0
- dos/archive_lock.py +266 -0
- dos/arg_provenance.py +814 -0
- dos/attest.py +472 -0
- dos/breaker.py +311 -0
- dos/churn.py +226 -0
- dos/claim_extract.py +229 -0
- dos/claim_ttl.py +150 -0
- dos/cli.py +8721 -0
- dos/commit_audit.py +666 -0
- dos/completion.py +466 -0
- dos/concurrency_class.py +154 -0
- dos/config.py +1380 -0
- dos/config_lint.py +464 -0
- dos/cooldown.py +390 -0
- dos/coverage.py +387 -0
- dos/dangling_intent.py +287 -0
- dos/data_class.py +397 -0
- dos/decisions.py +1274 -0
- dos/decisions_tui.py +251 -0
- dos/dispatch_top.py +740 -0
- dos/dispatch_top_tui.py +116 -0
- dos/drivers/__init__.py +40 -0
- dos/drivers/ci_status.py +630 -0
- dos/drivers/citation_resolve.py +703 -0
- dos/drivers/decision_stop.py +98 -0
- dos/drivers/export_file.py +173 -0
- dos/drivers/export_otlp.py +275 -0
- dos/drivers/export_statsd.py +242 -0
- dos/drivers/hook_dialects.py +391 -0
- dos/drivers/job.py +47 -0
- dos/drivers/llm_judge.py +360 -0
- dos/drivers/memory_recall.py +1231 -0
- dos/drivers/notify_slack.py +373 -0
- dos/drivers/notify_webhook.py +251 -0
- dos/drivers/operator_judge.py +114 -0
- dos/drivers/os_acceptance.py +228 -0
- dos/drivers/paste_log.py +132 -0
- dos/drivers/plan_scope.py +133 -0
- dos/drivers/self_improve.py +375 -0
- dos/drivers/similarity_judge.py +249 -0
- dos/drivers/state_diff.py +274 -0
- dos/drivers/supervisor.py +347 -0
- dos/drivers/watchdog.py +363 -0
- dos/drivers/workshop.py +160 -0
- dos/durable_schema.py +344 -0
- dos/effect_witness.py +393 -0
- dos/efficiency.py +318 -0
- dos/enforce.py +414 -0
- dos/enumerate.py +776 -0
- dos/env_print.py +378 -0
- dos/event_severity.py +258 -0
- dos/evidence.py +692 -0
- dos/exec_capability.py +256 -0
- dos/export_cursor.py +143 -0
- dos/exporter.py +320 -0
- dos/firing_label.py +353 -0
- dos/fleet_roll.py +226 -0
- dos/gate_classify.py +827 -0
- dos/gh4_coverage.py +179 -0
- dos/git_delta.py +122 -0
- dos/guard.py +215 -0
- dos/health.py +552 -0
- dos/help_summary.py +519 -0
- dos/home.py +934 -0
- dos/hook_binary.py +194 -0
- dos/hook_dialect.py +271 -0
- dos/hook_exit.py +191 -0
- dos/hook_install.py +437 -0
- dos/id_alloc.py +304 -0
- dos/improve.py +499 -0
- dos/intent_ledger.py +635 -0
- dos/interpret.py +176 -0
- dos/intervention.py +769 -0
- dos/intervention_eval.py +371 -0
- dos/journal_delta.py +308 -0
- dos/judge_eval.py +328 -0
- dos/judges.py +366 -0
- dos/lane_infer.py +127 -0
- dos/lane_journal.py +1001 -0
- dos/lane_lease.py +952 -0
- dos/lane_overlap.py +228 -0
- dos/lease_health.py +282 -0
- dos/lifecycle.py +211 -0
- dos/liveness.py +352 -0
- dos/lock_modes.py +185 -0
- dos/log_source.py +395 -0
- dos/loop_decide.py +1746 -0
- dos/marker_gate.py +254 -0
- dos/marker_sensor.py +396 -0
- dos/noop_streak.py +280 -0
- dos/notify.py +479 -0
- dos/observe.py +175 -0
- dos/oracle.py +1661 -0
- dos/overlap_eval.py +214 -0
- dos/overlap_policy.py +342 -0
- dos/packet_sidecar.py +267 -0
- dos/phase_shipped.py +1985 -0
- dos/pick_priority.py +225 -0
- dos/pickable.py +369 -0
- dos/picker_oracle.py +1037 -0
- dos/plan_board.py +513 -0
- dos/plan_board_tui.py +113 -0
- dos/plan_source.py +455 -0
- dos/posttool_sensor.py +528 -0
- dos/precursor_gate.py +499 -0
- dos/precursor_gate_eval.py +239 -0
- dos/preflight.py +825 -0
- dos/pretool_sensor.py +490 -0
- dos/proc_delta.py +181 -0
- dos/productivity.py +296 -0
- dos/provider_limit.py +242 -0
- dos/py.typed +4 -0
- dos/reason_morphology.py +299 -0
- dos/reasons.py +449 -0
- dos/reconcile.py +173 -0
- dos/recurring_wedge.py +206 -0
- dos/render.py +393 -0
- dos/result_state.py +468 -0
- dos/resume.py +578 -0
- dos/resume_evidence.py +293 -0
- dos/retention.py +344 -0
- dos/reward.py +372 -0
- dos/rewind.py +587 -0
- dos/rewind_evidence.py +168 -0
- dos/rewind_tokens.py +252 -0
- dos/run_id.py +342 -0
- dos/scope.py +520 -0
- dos/scope_source.py +382 -0
- dos/scout.py +982 -0
- dos/self_modify.py +209 -0
- dos/sibling_scan.py +569 -0
- dos/skills/EXAMPLES.md +584 -0
- dos/skills/dos-class-cycle/SKILL.md +107 -0
- dos/skills/dos-dispatch/SKILL.md +177 -0
- dos/skills/dos-dispatch-loop/SKILL.md +254 -0
- dos/skills/dos-goal-gate/SKILL.md +269 -0
- dos/skills/dos-next-up/SKILL.md +231 -0
- dos/skills/dos-promote/SKILL.md +114 -0
- dos/skills/dos-replan/SKILL.md +159 -0
- dos/skills/dos-replan-loop/SKILL.md +114 -0
- dos/skills/dos-self-improve/SKILL.md +213 -0
- dos/skills/dos-supervise-loop/SKILL.md +180 -0
- dos/skills/dos-unstick/SKILL.md +108 -0
- dos/skills/dos-witness-claim/SKILL.md +251 -0
- dos/stamp.py +1002 -0
- dos/state_health.py +387 -0
- dos/status.py +114 -0
- dos/stop_policy.py +334 -0
- dos/supervise.py +1014 -0
- dos/testwitness.py +392 -0
- dos/timeline.py +1027 -0
- dos/tokens.py +485 -0
- dos/tool_stream.py +393 -0
- dos/tool_stream_eval.py +226 -0
- dos/trace.py +524 -0
- dos/verdict.py +140 -0
- dos/verdict_cli.py +189 -0
- dos/verdict_journal.py +497 -0
- dos/verdict_rollup.py +217 -0
- dos/verdicts.py +181 -0
- dos/wedge_reason.py +282 -0
- dos_kernel-0.22.0.dist-info/METADATA +859 -0
- dos_kernel-0.22.0.dist-info/RECORD +178 -0
- dos_kernel-0.22.0.dist-info/WHEEL +5 -0
- dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
- dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
- dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
- dos_mcp/__init__.py +52 -0
- dos_mcp/py.typed +2 -0
- 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")
|