instar 1.3.762 → 1.3.764
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.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +92 -1
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +28 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/FileClassifier.d.ts.map +1 -1
- package/dist/core/FileClassifier.js +5 -0
- package/dist/core/FileClassifier.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts +12 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +61 -2
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
- package/dist/core/WriteDomainRegistry.js +16 -0
- package/dist/core/WriteDomainRegistry.js.map +1 -1
- package/dist/core/componentCategories.d.ts.map +1 -1
- package/dist/core/componentCategories.js +1 -0
- package/dist/core/componentCategories.js.map +1 -1
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +6 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/types.d.ts +50 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/data/llmBenchCoverage.d.ts.map +1 -1
- package/dist/data/llmBenchCoverage.js +18 -0
- package/dist/data/llmBenchCoverage.js.map +1 -1
- package/dist/monitoring/ExternalHogArmMarker.d.ts +65 -0
- package/dist/monitoring/ExternalHogArmMarker.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogArmMarker.js +0 -0
- package/dist/monitoring/ExternalHogArmMarker.js.map +1 -0
- package/dist/monitoring/ExternalHogArmStore.d.ts +46 -0
- package/dist/monitoring/ExternalHogArmStore.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogArmStore.js +124 -0
- package/dist/monitoring/ExternalHogArmStore.js.map +1 -0
- package/dist/monitoring/ExternalHogClassifier.d.ts +62 -0
- package/dist/monitoring/ExternalHogClassifier.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogClassifier.js +0 -0
- package/dist/monitoring/ExternalHogClassifier.js.map +1 -0
- package/dist/monitoring/ExternalHogClassifierPrompt.d.ts +29 -0
- package/dist/monitoring/ExternalHogClassifierPrompt.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogClassifierPrompt.js +70 -0
- package/dist/monitoring/ExternalHogClassifierPrompt.js.map +1 -0
- package/dist/monitoring/ExternalHogCpuDelta.d.ts +50 -0
- package/dist/monitoring/ExternalHogCpuDelta.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogCpuDelta.js +79 -0
- package/dist/monitoring/ExternalHogCpuDelta.js.map +1 -0
- package/dist/monitoring/ExternalHogFactBuilder.d.ts +71 -0
- package/dist/monitoring/ExternalHogFactBuilder.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogFactBuilder.js +111 -0
- package/dist/monitoring/ExternalHogFactBuilder.js.map +1 -0
- package/dist/monitoring/ExternalHogFloor.d.ts +87 -0
- package/dist/monitoring/ExternalHogFloor.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogFloor.js +123 -0
- package/dist/monitoring/ExternalHogFloor.js.map +1 -0
- package/dist/monitoring/ExternalHogGuardStatus.d.ts +29 -0
- package/dist/monitoring/ExternalHogGuardStatus.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogGuardStatus.js +27 -0
- package/dist/monitoring/ExternalHogGuardStatus.js.map +1 -0
- package/dist/monitoring/ExternalHogKillFunnel.d.ts +77 -0
- package/dist/monitoring/ExternalHogKillFunnel.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogKillFunnel.js +72 -0
- package/dist/monitoring/ExternalHogKillFunnel.js.map +1 -0
- package/dist/monitoring/ExternalHogKillLedger.d.ts +76 -0
- package/dist/monitoring/ExternalHogKillLedger.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogKillLedger.js +84 -0
- package/dist/monitoring/ExternalHogKillLedger.js.map +1 -0
- package/dist/monitoring/ExternalHogNoticeCoalescer.d.ts +46 -0
- package/dist/monitoring/ExternalHogNoticeCoalescer.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogNoticeCoalescer.js +67 -0
- package/dist/monitoring/ExternalHogNoticeCoalescer.js.map +1 -0
- package/dist/monitoring/ExternalHogOwnership.d.ts +48 -0
- package/dist/monitoring/ExternalHogOwnership.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogOwnership.js +60 -0
- package/dist/monitoring/ExternalHogOwnership.js.map +1 -0
- package/dist/monitoring/ExternalHogProcTable.d.ts +37 -0
- package/dist/monitoring/ExternalHogProcTable.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogProcTable.js +65 -0
- package/dist/monitoring/ExternalHogProcTable.js.map +1 -0
- package/dist/monitoring/ExternalHogRealAdapters.d.ts +91 -0
- package/dist/monitoring/ExternalHogRealAdapters.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogRealAdapters.js +247 -0
- package/dist/monitoring/ExternalHogRealAdapters.js.map +1 -0
- package/dist/monitoring/ExternalHogSampler.d.ts +62 -0
- package/dist/monitoring/ExternalHogSampler.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogSampler.js +0 -0
- package/dist/monitoring/ExternalHogSampler.js.map +1 -0
- package/dist/monitoring/ExternalHogScanTick.d.ts +89 -0
- package/dist/monitoring/ExternalHogScanTick.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogScanTick.js +126 -0
- package/dist/monitoring/ExternalHogScanTick.js.map +1 -0
- package/dist/monitoring/ExternalHogSentinel.d.ts +127 -0
- package/dist/monitoring/ExternalHogSentinel.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogSentinel.js +168 -0
- package/dist/monitoring/ExternalHogSentinel.js.map +1 -0
- package/dist/monitoring/ExternalHogServerPrimitives.d.ts +50 -0
- package/dist/monitoring/ExternalHogServerPrimitives.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogServerPrimitives.js +93 -0
- package/dist/monitoring/ExternalHogServerPrimitives.js.map +1 -0
- package/dist/monitoring/ExternalHogSustained.d.ts +49 -0
- package/dist/monitoring/ExternalHogSustained.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogSustained.js +62 -0
- package/dist/monitoring/ExternalHogSustained.js.map +1 -0
- package/dist/monitoring/guardManifest.d.ts.map +1 -1
- package/dist/monitoring/guardManifest.js +24 -0
- package/dist/monitoring/guardManifest.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +3 -2
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +3 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +1 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +1 -0
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/routes.d.ts +3 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +79 -5
- package/dist/server/routes.js.map +1 -1
- package/dist/testing/selfActionRegistry.d.ts.map +1 -1
- package/dist/testing/selfActionRegistry.js +51 -0
- package/dist/testing/selfActionRegistry.js.map +1 -1
- package/package.json +1 -1
- package/scripts/lint-scrape-fixture-realness.js +14 -0
- package/src/data/builtin-manifest.json +64 -64
- package/src/data/llmBenchCoverage.ts +18 -0
- package/src/scaffold/templates.ts +3 -2
- package/upgrades/1.3.763.md +23 -0
- package/upgrades/1.3.764.md +45 -0
- package/upgrades/side-effects/actionclaim-config-shape-fix.eli16.md +50 -0
- package/upgrades/side-effects/actionclaim-config-shape-fix.md +149 -0
- package/upgrades/side-effects/external-hog-sentinel.md +832 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
# Side-Effects Review — External-Hog Zombie Auto-Kill Sentinel
|
|
2
|
+
|
|
3
|
+
Spec: `docs/specs/external-hog-zombie-autokill-sentinel.md` (CMT-1901, converged 11 rounds, approved by operator 2026-07-03).
|
|
4
|
+
Build branch: `echo/external-hog-sentinel` off `JKHeadley/main` @ v1.3.748.
|
|
5
|
+
Tier: **2** (safety-critical, irreversible action — process kill).
|
|
6
|
+
|
|
7
|
+
## Phase 1 — Principle check (signal vs authority)
|
|
8
|
+
|
|
9
|
+
**Does this change involve a decision point?** YES — it gates an irreversible action
|
|
10
|
+
(killing an external OS process).
|
|
11
|
+
|
|
12
|
+
**Compliance:** The design is signal-vs-authority-COMPLIANT and this was the single
|
|
13
|
+
most-reviewed axis across 11 rounds. Split of authority:
|
|
14
|
+
- The **mechanical safety floor** is a VETO-ONLY hard-invariant guard on an
|
|
15
|
+
irreversible action — the allowed "brittle blocker" class (a hard invariant guarding
|
|
16
|
+
an irreversible action, per `docs/signal-vs-authority.md`). It can only ever BLOCK a
|
|
17
|
+
kill (downgrade to alert); it can never trigger one.
|
|
18
|
+
- The **LLM classifier** holds the judgment authority (the smart gate) — kill/leave/
|
|
19
|
+
alert — fed the deterministically-computed facts. Its authority is purely
|
|
20
|
+
SUBTRACTIVE: `kill executes iff floor_pass && classifier === 'kill'`.
|
|
21
|
+
- The **observability floor** is a signal producer: every confirmed sustained hog that
|
|
22
|
+
is not killed is surfaced; the model cannot suppress it.
|
|
23
|
+
|
|
24
|
+
This is the correct shape: brittle deterministic logic produces hard vetoes + signals;
|
|
25
|
+
the intelligent full-context gate makes the judgment call. Adding blocking authority to
|
|
26
|
+
a brittle check is exactly what the design AVOIDS — the floor never authorizes, only
|
|
27
|
+
refuses.
|
|
28
|
+
|
|
29
|
+
## Phase 2 — Plan (build location + interactions)
|
|
30
|
+
|
|
31
|
+
- **Build location (re-grounded):** FRESH worktree off `JKHeadley/main` @ **v1.3.748**
|
|
32
|
+
(the agent-home checkout was 243 commits behind — building here would be stale). git
|
|
33
|
+
remote = JKHeadley; per-worktree identity set by `instar worktree create`.
|
|
34
|
+
- **Re-grounding against master (all confirmed present):** `resolveOwningSession(pid,
|
|
35
|
+
tree, tmuxPaneMap, maxHops)` (McpProcessReaper.ts:111); `parseProcTimeToSeconds`
|
|
36
|
+
(SessionManager.ts:201) — the load-bearing parser to register in `SCRAPE_PARSERS`;
|
|
37
|
+
`devGatedFeatures.ts` with `credentialRepointing` (the Posture-A precedent) + the
|
|
38
|
+
sibling destructive killers `sessionReaper`/`agentWorktreeReaper`/`mcpProcessReaper`
|
|
39
|
+
(DARK_GATE_EXCLUSIONS); `migrateDevGateTeethStrip` (PostUpdateMigrator.ts:1353,
|
|
40
|
+
"Allowlist is HARDCODED"). `zombie-classify.json` benchmark brought into the branch
|
|
41
|
+
(was not in master).
|
|
42
|
+
- **Interactions:** de-conflict with McpProcessReaper + SessionManager (exclude their
|
|
43
|
+
pids from discovery + kill), suppress OrphanProcessReaper's legacy external-report for
|
|
44
|
+
in-lane pids; ride the shared LlmQueue background lane + hostSpawnCap; wire into
|
|
45
|
+
guard-posture + the Guard-Posture Tripwire.
|
|
46
|
+
- **Rollback path:** ships dev-gated dark-on-fleet + `dryRun:true` (kills nothing);
|
|
47
|
+
live killing requires the PIN arm route. Back-out = disarm route (writes dryRun:true +
|
|
48
|
+
invalidates the marker) or config `enabled:false`; no data migration; feature removal
|
|
49
|
+
leaves inert leftover state (sampler snapshot, kill ledger, audit log).
|
|
50
|
+
|
|
51
|
+
## Phase 4 — Side-effects review (per-question)
|
|
52
|
+
|
|
53
|
+
_(Filled in progressively as slices land; this baseline commit is the spec + benchmark
|
|
54
|
+
+ plan artifacts brought into the branch — no runtime `src/` change yet.)_
|
|
55
|
+
|
|
56
|
+
1. **Over-block:** N/A for the baseline; the runtime design over-blocks toward NOT
|
|
57
|
+
killing (every uncertainty → alert-never-kill), which is the safe direction.
|
|
58
|
+
2. **Under-block:** the narrow v1 allowlist deliberately does NOT auto-kill non-exthost
|
|
59
|
+
hogs (they are surfaced, not killed) — an accepted, evidence-gated scope.
|
|
60
|
+
3. **Level-of-abstraction fit:** owns its own uid-scoped host-process discovery because
|
|
61
|
+
the existing OrphanProcessReaper is structurally blind to the target class
|
|
62
|
+
(framework-needle pre-filter). Correct layer.
|
|
63
|
+
4. **Signal vs authority:** compliant (Phase 1).
|
|
64
|
+
5. **Interactions:** de-conflicted (Phase 2).
|
|
65
|
+
6. **External surfaces:** new `GET/POST /external-hog*` routes (Bearer/PIN-authed);
|
|
66
|
+
coalesced Telegram notice on the deterministic delivery path; guard-posture row.
|
|
67
|
+
7. **Multi-machine posture:** machine-local BY DESIGN (`hardware-bound-resource` — a host
|
|
68
|
+
OS process is bound to one kernel); cross-machine visibility via each machine's own
|
|
69
|
+
`GET /external-hog` + pool-scope `/guards`. Confirmed correct by review.
|
|
70
|
+
8. **Rollback cost:** low — disarm route / config flip; no migration.
|
|
71
|
+
|
|
72
|
+
## Slice log
|
|
73
|
+
|
|
74
|
+
### Slice 2 — config schema + dev-gate registration
|
|
75
|
+
Files: `src/core/types.ts` (the `externalHogSentinel?` interface), `src/config/ConfigDefaults.ts`
|
|
76
|
+
(defaults — `enabled` OMITTED, `dryRun: true`, all kill-gate knobs), `src/core/devGatedFeatures.ts`
|
|
77
|
+
(DEV_GATED_FEATURES entry with the credentialRepointing-style justification),
|
|
78
|
+
`tests/unit/external-hog-sentinel-config.test.ts`.
|
|
79
|
+
- **Side effects:** NONE at runtime — this slice adds a DORMANT config block + a registry
|
|
80
|
+
entry. No code consumes it yet, so no behavior changes on any agent. It resolves the
|
|
81
|
+
§7/§8 obligation that `enabled` be omitted (dev-gate) + registered in DEV_GATED_FEATURES
|
|
82
|
+
(the #1001 wiring guarantee — auto-covered by `devGatedFeatures-wiring.test.ts`, 151 pass).
|
|
83
|
+
- **Over/under-block:** N/A (no decision logic yet).
|
|
84
|
+
- **Signal vs authority:** N/A (config only).
|
|
85
|
+
- **Multi-machine:** config is machine-local; the feature's posture is `hardware-bound-resource`.
|
|
86
|
+
- **Tests:** 6 focused (dev-gate live/dark, dryRun canary on both, enabled-omitted, all
|
|
87
|
+
defaults, the `maxClassificationsPerScan < hostSpawnCap` invariant) + the 151-test wiring
|
|
88
|
+
suite. Typecheck clean.
|
|
89
|
+
|
|
90
|
+
### Slice 3 — deterministic safety floor (the veto-only kill envelope)
|
|
91
|
+
Files: `src/monitoring/ExternalHogFloor.ts` (the `ExternalHogFacts` type, the code-defined
|
|
92
|
+
`EXTERNAL_HOG_ALLOWLIST` + `matchAllowlistClass`, and `evaluateKillFloor` — the pure
|
|
93
|
+
veto-only predicate), `tests/unit/external-hog-floor.test.ts` (24 tests).
|
|
94
|
+
- **Over-block:** the floor fails CLOSED — any unknown invariant (unknown uid, missing
|
|
95
|
+
field) → NOT permitted → alert. It "over-blocks" toward NOT killing, which is the correct
|
|
96
|
+
safe direction for an irreversible action. It does not reject any legitimate KILL that a
|
|
97
|
+
real orphaned in-envelope zombie would need (the anchor case permits).
|
|
98
|
+
- **Under-block:** the floor is a NECESSARY, not sufficient, condition — it never authorizes
|
|
99
|
+
a kill on its own (the caller must ALSO have `classifier==='kill'`). It cannot under-block
|
|
100
|
+
a kill because it only ever removes kills.
|
|
101
|
+
- **Level-of-abstraction fit:** pure function over a normalized fact set; the discovery/
|
|
102
|
+
sampler layer computes the facts, the floor evaluates them, the caller ANDs with the model
|
|
103
|
+
verdict. Correct separation.
|
|
104
|
+
- **Signal vs authority:** COMPLIANT — this is the hard-invariant guard on an irreversible
|
|
105
|
+
action (the allowed brittle-blocker class). It holds VETO authority only; the model holds
|
|
106
|
+
the judgment. It can only BLOCK a kill, never trigger one (structurally: the return type is
|
|
107
|
+
permit-or-veto, and the only `permitted:true` path requires every invariant to pass).
|
|
108
|
+
- **Interactions:** the allowlist is code-defined (not config), so no runtime widening;
|
|
109
|
+
instar-own exclusion is checked first (defense-in-depth vs the discovery-layer exclusion).
|
|
110
|
+
- **Multi-machine:** pure logic, no state — N/A.
|
|
111
|
+
- **External surfaces:** none — no I/O, no routes, no messages. Dormant until a runtime
|
|
112
|
+
consumer wires it (later slices).
|
|
113
|
+
- **Rollback:** delete the module; nothing consumes it yet.
|
|
114
|
+
- **Tests:** 24 — allowlist match (name+token, attacker-name inert, anchored regex), the
|
|
115
|
+
anchor permits, EVERY invariant load-bearing (instar-own, other-uid, root-euid, unknown-uid,
|
|
116
|
+
root-daemon, launchctl, live-parent, not-sustained, outside-allowlist ×2), and the 8
|
|
117
|
+
zombie-classify cases as floor fixtures (permits only the exthost-kill case). Typecheck clean.
|
|
118
|
+
|
|
119
|
+
### Slice 4 — CPU-delta signal core (monotonic clock, fail-closed)
|
|
120
|
+
Files: `src/monitoring/ExternalHogCpuDelta.ts` (`monotonicNowMs`, `computeCoreEquivalents`,
|
|
121
|
+
`meetsThreshold`, the `CPU_DELTA_UNKNOWN` sentinel), `tests/unit/external-hog-cpu-delta.test.ts`
|
|
122
|
+
(12 tests).
|
|
123
|
+
- **What it is:** the pure `Δcputime / Δwall` core-equivalents computation feeding the
|
|
124
|
+
`sustainedHighCpu` floor invariant. Δwall is a MONOTONIC clock (sleep-paused, NTP-immune).
|
|
125
|
+
- **Over/under-block:** it produces a SIGNAL, not a decision — it never kills. Its only
|
|
126
|
+
safety obligation is to never produce a FALSE "sustained hog" (which the floor would then
|
|
127
|
+
treat as a passing invariant). It fails CLOSED: non-positive Δwall, implausibly-large Δwall
|
|
128
|
+
(a sleep slipped through), a decreasing counter (pid reuse), or any non-finite input →
|
|
129
|
+
`CPU_DELTA_UNKNOWN`, and `meetsThreshold(UNKNOWN, …) === false` — so an unknown reading is
|
|
130
|
+
NEVER a confirmed hog. It cannot over-report an idle process as sustained.
|
|
131
|
+
- **Signal vs authority:** signal-only; feeds the deterministic floor. Compliant.
|
|
132
|
+
- **Multi-machine:** pure logic, no state — N/A.
|
|
133
|
+
- **External surfaces:** none — no I/O, no subprocess (the actual `ps` sampling is a later
|
|
134
|
+
slice; this is only the delta math over samples).
|
|
135
|
+
- **Rollback:** delete the module; nothing consumes it yet.
|
|
136
|
+
- **Tests:** 12 — core-equiv math (2 cores / idle / 1 core), fail-closed on every implausible
|
|
137
|
+
interval (backward/same-instant Δwall, 3h-sleep Δwall, decreasing counter, NaN/Infinity),
|
|
138
|
+
jitter tolerance within the factor, `meetsThreshold` (UNKNOWN never a hog), monotonicity.
|
|
139
|
+
|
|
140
|
+
### Slice 5 — armed-marker gate (the doubly-held "can this kill" logic)
|
|
141
|
+
Files: `src/monitoring/ExternalHogArmMarker.ts` (`classContentHash`, `isMarkerValid`,
|
|
142
|
+
`classIsArmed`, `canKillLive`), `tests/unit/external-hog-arm-marker.test.ts` (16 tests).
|
|
143
|
+
- **What it is:** the second key holding a LIVE kill (beyond enabled && !dryRun) — a valid
|
|
144
|
+
PIN-written marker with the arm-epoch lifecycle + the per-class content-hash arm-scope.
|
|
145
|
+
- **Signal vs authority:** pure authorization predicate; never kills. Fails CLOSED on any
|
|
146
|
+
missing/invalid input (no marker, non-finite epoch, dryRun:true, disarmed).
|
|
147
|
+
- **Security properties (round-9 review, now in code):** (1) armEpoch > lastDisarmEpoch — a
|
|
148
|
+
disarm can never be silently un-done; `disarm→config dryRun:false→restart` boots UNARMED;
|
|
149
|
+
config.dryRun:false is NEVER a positive arm. (2) per-class content-hash — new/broadened
|
|
150
|
+
class → not armed → alert-only until PIN re-arm; unrelated class add doesn't disarm others.
|
|
151
|
+
- **External surfaces:** none yet (the arm/disarm ROUTES that write the marker are a later
|
|
152
|
+
slice); this is the pure predicate they'll consume. Multi-machine: pure logic.
|
|
153
|
+
- **Rollback:** delete the module; nothing consumes it yet.
|
|
154
|
+
- **Tests:** 16 — content-hash determinism/change-sensitivity/order, epoch validity + the
|
|
155
|
+
disarm-restart-bypass-closed, per-class armed/new/broadened/unrelated, canKillLive doubly-
|
|
156
|
+
held (dryRun-never-kills, bare-config-flip-never-arms, enabled:false, unarmed class).
|
|
157
|
+
|
|
158
|
+
### Slice 6 — P19 loop brakes (kill-ledger + respawn breaker + in-flight set)
|
|
159
|
+
Files: `src/monitoring/ExternalHogKillLedger.ts` (`recordKill`, `isBreakerTripped`,
|
|
160
|
+
`killCountInWindow`, `shouldEvictInFlight`, `isInFlight`), `tests/unit/external-hog-kill-ledger.test.ts` (14 tests).
|
|
161
|
+
- **What it is:** the pure state machines that STOP a kill-respawn loop (#863). After K kills
|
|
162
|
+
of the same respawn-surviving key in a rolling window → breaker trips (stop killing +
|
|
163
|
+
surface). A VOLATILE key falls back to a CLASS-level breaker (a per-volatile-key count could
|
|
164
|
+
never accumulate). The in-flight set stops re-killing a SIGTERM'd pid; eviction on confirmed
|
|
165
|
+
exit or the TTL.
|
|
166
|
+
- **Safe direction:** for a LOOP BRAKE the safe failure is TRIP (stop killing) — a non-finite
|
|
167
|
+
window/now returns tripped rather than risk an unbounded loop. The breaker shields same-key
|
|
168
|
+
hogs from KILL only, never from the §4 observability floor.
|
|
169
|
+
- **Bounded:** `recordKill` prunes past the retention bound so the durable ledger can't grow
|
|
170
|
+
without limit. In-flight eviction (confirmed-exit OR ttl, ~3×sigtermGrace) prevents a leak
|
|
171
|
+
and prevents re-killing a mid-write LS early.
|
|
172
|
+
- **External surfaces:** none yet (durable persistence + the actual kill are the caller's);
|
|
173
|
+
pure logic. Multi-machine: machine-local (the kill ledger is per-machine).
|
|
174
|
+
- **Rollback:** delete the module; nothing consumes it yet.
|
|
175
|
+
- **Tests:** 14 — append+prune, per-key breaker (trip at K, ignore out-of-window, a decoy
|
|
176
|
+
can't shield another key), volatile→class fallback, safe-trip on bad window inputs, in-flight
|
|
177
|
+
detect + pid-reuse distinction + TTL/confirmed-exit/non-finite eviction.
|
|
178
|
+
|
|
179
|
+
### Slice 7 — instar-own exclusion (ancestry walk, start-time-aware, own-root fallback)
|
|
180
|
+
Files: `src/monitoring/ExternalHogOwnership.ts` (`isInstarOwned` over a ProcTree snapshot),
|
|
181
|
+
`tests/unit/external-hog-ownership.test.ts` (11 tests).
|
|
182
|
+
- **What it is:** the pure ancestry walk that EXCLUDES a candidate whose chain reaches a
|
|
183
|
+
start-time-verified instar-owned pid (tmux pane OR own-root), so own busy build children
|
|
184
|
+
(vitest/tsc) aren't flagged as hogs. Extends resolveOwningSession with per-hop start-time.
|
|
185
|
+
- **Dangerous direction:** a false "owned" would HIDE a real external hog. That requires a
|
|
186
|
+
start-time MATCH on an owned pid, so an external hog can't fake it. INCLUDE-on-uncertainty
|
|
187
|
+
(anti-evasion): an unresolvable edge / cycle / hop-bound → NOT owned (candidate stays);
|
|
188
|
+
instar-own KILL protection is carried by reparent-to-pid-1 + the §4 allowlist floor, not
|
|
189
|
+
this walk — so INCLUDE-on-uncertainty costs only observability noise, never a wrong kill.
|
|
190
|
+
- **Signal vs authority:** produces a candidacy signal; never kills. Multi-machine: pure.
|
|
191
|
+
- **External surfaces:** none yet (the ps read that builds the tree + the owned-pid set are a
|
|
192
|
+
later I/O slice). Rollback: delete; nothing consumes it yet.
|
|
193
|
+
- **Tests:** 11 — direct/deep/self owned; genuine orphan (ppid 1) not owned; start-time
|
|
194
|
+
defeats pid reuse (reused number ≠ owned, matching start-time excludes); unresolvable edge
|
|
195
|
+
/ cycle / hop-bound → not owned; invalid inputs → not owned.
|
|
196
|
+
|
|
197
|
+
### Slice 8 — P17 notice coalescer (notification bounding)
|
|
198
|
+
Files: `src/monitoring/ExternalHogNoticeCoalescer.ts` (`coalesceNotices`),
|
|
199
|
+
`tests/unit/external-hog-notice-coalescer.test.ts` (9 tests).
|
|
200
|
+
- **What it is:** the pure P17 selection logic — one coalescing chokepoint over all notice
|
|
201
|
+
classes (kill / decider-unavailable / floor-veto-downgrade / hog-left-alive) with
|
|
202
|
+
per-signature dedup, a per-window budget, severity ordering on exhaustion, and live KILLS
|
|
203
|
+
always piercing the budget. It NEVER kills — it selects which NOTICES to emit vs drop.
|
|
204
|
+
- **Signal vs authority:** notification bounding, NOT a kill/block decision. The safety-
|
|
205
|
+
critical second-pass (kill logic) does not apply; the risk is bounded (worst case: a dropped
|
|
206
|
+
LOW-severity notice — a kill notice can never be dropped, and dedup prevents a flood).
|
|
207
|
+
- **Multi-machine:** pure, machine-local (notices are per-machine). External surfaces: none
|
|
208
|
+
yet (the actual delivery + window state are the caller's).
|
|
209
|
+
- **Rollback:** delete; nothing consumes it yet.
|
|
210
|
+
- **Tests:** 9 — dedup (in-batch, vs-window, different-class-not-deduped), budget + severity
|
|
211
|
+
ordering (keeps highest severity, reports dropped-by-class), kills-always-pierce, robustness
|
|
212
|
+
(zero/negative/NaN budget, malformed notice ignored, empty batch).
|
|
213
|
+
|
|
214
|
+
### Slice 9 — ps whole-table parser + realness fixture (load-bearing, SCRAPE_PARSERS)
|
|
215
|
+
Files: `src/monitoring/ExternalHogProcTable.ts` (`parseProcTable`),
|
|
216
|
+
`tests/unit/external-hog-proc-table.test.ts` (5 tests), the captured fixture
|
|
217
|
+
`tests/fixtures/captured/ps-proc-table/{table.txt,table.meta.json}`, and the SCRAPE_PARSERS
|
|
218
|
+
registration in `scripts/lint-scrape-fixture-realness.js`.
|
|
219
|
+
- **What it is:** the whole-table `ps -o pid=,ppid=,uid=,lstart=,time=,comm=` parse into rows
|
|
220
|
+
(pid/ppid/uid/startTime/cputimeSeconds/comm) the sampler uses. LOAD-BEARING for kill
|
|
221
|
+
eligibility (the CPU-delta pivots on `time=`), so REGISTERED in SCRAPE_PARSERS with a
|
|
222
|
+
captured realness fixture (§Testing F1, resolves the round-9 lessons-aware blocker).
|
|
223
|
+
- **Dangerous direction:** a parse bug that OVER-reports cputime → a false sustained hog. The
|
|
224
|
+
fixture proves the parser survives the real structural bytes (dd- day-prefix, embedded-space
|
|
225
|
+
lstart + comm, <defunct>, malformed short) and fails CLOSED: unidentifiable pid/ppid/uid →
|
|
226
|
+
row SKIPPED; malformed `time=` → cputimeSeconds undefined → CPU-delta UNKNOWN → alert-never-kill.
|
|
227
|
+
- **Signal vs authority:** parsing only; feeds the deterministic CPU-delta. Multi-machine: pure.
|
|
228
|
+
- **External surfaces:** the parser is pure (the actual `ps` spawn is the sampler slice).
|
|
229
|
+
parseProcTimeToSeconds stays a non-blocking register-or-justify note (a field parser, not a
|
|
230
|
+
whole-output parser — exercised transitively through this fixture).
|
|
231
|
+
- **Rollback:** delete the module + fixture + registration entry.
|
|
232
|
+
- **Tests:** 5 — the byte-for-byte fixture parse (7 rows, 1 malformed skipped; dd- anchor =
|
|
233
|
+
106920s; comm-with-spaces preserved; <defunct>), the time= realness (dd- day-prefix), and
|
|
234
|
+
fail-closed (malformed time → undefined, non-numeric pid → skip, non-string → []).
|
|
235
|
+
|
|
236
|
+
### Slice 10 — stage-1 candidacy state machine (pure)
|
|
237
|
+
Files: `src/monitoring/ExternalHogSampler.ts` (`advanceSampler`, `isSamplerDead`),
|
|
238
|
+
`tests/unit/external-hog-sampler.test.ts` (13 tests).
|
|
239
|
+
- **What it is:** the pure stage-1 candidacy computation — from two successive ps snapshots it
|
|
240
|
+
selects external (own-uid, not instar-owned) processes whose cross-tick Δcputime/Δwall
|
|
241
|
+
crosses the threshold. Integrates the CPU-delta + ownership walk + parsed rows. Rebuilds the
|
|
242
|
+
identity map each tick (bounded). A liveness heartbeat advances only on a plausible parse.
|
|
243
|
+
- **Dangerous direction:** a false CANDIDATE (an idle process flagged as a hog). Guarded: uses
|
|
244
|
+
the DELTA not lifetime average (emergent hog caught, idle not); different-uid / instar-owned /
|
|
245
|
+
unknown-cputime / first-sight → never a candidate; UNKNOWN delta → excluded.
|
|
246
|
+
- **Signal vs authority:** produces a candidacy signal; never kills. The actual ps spawn + wall
|
|
247
|
+
clock live in the thin I/O worker shell (later); this is pure. Multi-machine: pure.
|
|
248
|
+
- **Heartbeat:** advances on any plausible parse (≥1 row) regardless of candidate count (idle
|
|
249
|
+
machine not sampler-dead); a failed/empty parse does NOT advance (→ eventually sampler-dead)
|
|
250
|
+
and keeps the previous baseline (a transient hiccup doesn't lose the delta baseline).
|
|
251
|
+
- **Rollback:** delete; the sentinel class (later) consumes it.
|
|
252
|
+
- **Tests:** 13 — baseline/candidate/emergent-hog/low-cpu; exclusions (uid, instar-own, unknown
|
|
253
|
+
cputime); heartbeat (advances on zero-candidate plausible parse, not on empty/non-finite);
|
|
254
|
+
isSamplerDead (null=not-dead, fresh/stale, non-finite-now=not-dead).
|
|
255
|
+
|
|
256
|
+
### Slice 11 — classifier orchestration (pure: verdict parse, cap-select, cache)
|
|
257
|
+
Files: `src/monitoring/ExternalHogClassifier.ts` (`parseClassifierVerdict`,
|
|
258
|
+
`selectForClassification`, the TTL+LRU `VerdictCache`), `tests/unit/external-hog-classifier.test.ts` (13 tests).
|
|
259
|
+
- **What it is:** the pure orchestration around the model call (the actual LlmQueue call is the
|
|
260
|
+
sentinel-class adapter). Verdict parse (bounded enum, fail-safe), worst-CPU-first selection
|
|
261
|
+
under maxClassificationsPerScan, and the identity-tuple verdict cache.
|
|
262
|
+
- **Dangerous direction:** `parseClassifierVerdict` returning 'kill' when it shouldn't. Guarded:
|
|
263
|
+
ONLY an exact `kill`/`leave`/`alert` (bare or `{action}`) parses; anything else → null →
|
|
264
|
+
decider-unavailable → ALERT, never kill. NEVER extracts a pid/target from output.
|
|
265
|
+
- **Cap-select:** worst-CPU-first so a decoy flood can't starve the real hog; deterministic
|
|
266
|
+
non-attacker tie-break; non-positive cap → classify none.
|
|
267
|
+
- **Cache:** keyed on the FULL identity tuple (pid+start-time+command-hash) so a reused pid
|
|
268
|
+
can't inherit a prior `kill`; TTL+LRU; advisory (the §4 kill-time re-check is authoritative;
|
|
269
|
+
a non-finite now/ttl → miss).
|
|
270
|
+
- **Signal vs authority:** the model decides WITHIN the floor; this is the pure plumbing. Rollback:
|
|
271
|
+
delete; the sentinel class consumes it. Multi-machine: pure.
|
|
272
|
+
- **Tests:** 13 — verdict parse (enum, JSON, unparseable→null, no-pid-extraction), worst-CPU-first
|
|
273
|
+
select (decoy-flood can't starve, tie-break, non-positive cap), cache (TTL, reused-pid-no-inherit,
|
|
274
|
+
LRU eviction, non-finite→miss).
|
|
275
|
+
|
|
276
|
+
### Slice 12 — kill funnel (the hardened SIGTERM→SIGKILL sequence, the ONLY signal path)
|
|
277
|
+
Files: `src/monitoring/ExternalHogKillFunnel.ts` (`runKillFunnel`),
|
|
278
|
+
`tests/unit/external-hog-kill-funnel.test.ts` (10 tests).
|
|
279
|
+
- **What it is:** the ONLY place a real signal is sent. The watch-only guarantee is BY
|
|
280
|
+
CONSTRUCTION: unless canKillLive (enabled && !dryRun && a valid PIN marker for this class) at
|
|
281
|
+
BOTH re-check points, NO signal is sent (returns `would-kill`). All I/O injected → fully
|
|
282
|
+
testable without killing anything. Sequence: pre-SIGTERM arm-gate + Stage-B floor re-check →
|
|
283
|
+
SIGTERM → grace → exited? → pre-SIGKILL re-check (disarm/identity/floor mid-grace aborts) →
|
|
284
|
+
fd-skip defer (bounded) → SIGKILL.
|
|
285
|
+
- **Dangerous direction:** sending SIGKILL without full authorization. Guarded: canKillLive at
|
|
286
|
+
entry (would-kill/no-signal in watch-only) AND re-checked before SIGKILL; a disarm/identity-
|
|
287
|
+
change/floor-veto mid-grace aborts the escalation (the graceful SIGTERM already sent is not
|
|
288
|
+
forced to SIGKILL); class re-matched at kill time; fd-write defers (capped).
|
|
289
|
+
- **Signal vs authority:** executes the floor+model decision; never decides. Multi-machine:
|
|
290
|
+
machine-local (a host process). Rollback: delete; the sentinel class consumes it.
|
|
291
|
+
- **Tests:** 10 — dryRun/not-armed/disarmed → would-kill NO SIGNAL; floor-veto/gone → aborted NO
|
|
292
|
+
SIGNAL; sigterm-exit (SIGTERM only); full kill (SIGTERM+SIGKILL); disarm-mid-grace → aborted
|
|
293
|
+
(SIGTERM only, no SIGKILL); fd-write defer (SIGTERM only); defer-cap-exhausted → SIGKILL.
|
|
294
|
+
|
|
295
|
+
### Slice 13 — scan-tick orchestrator (composes all modules; the feature is ALIVE)
|
|
296
|
+
Files: `src/monitoring/ExternalHogScanTick.ts` (`runScanTick`),
|
|
297
|
+
`tests/unit/external-hog-scan-tick.test.ts` (6 tests, end-to-end over injected I/O).
|
|
298
|
+
- **What it is:** the orchestrator tying every reviewed module into ONE scan tick: discovery
|
|
299
|
+
(sampler) → worst-CPU-first classify under the cap → floor → P19 breaker → kill funnel → P17
|
|
300
|
+
coalesced notices. All I/O injected (read ps, ownership, classify, funnel deps, clock,
|
|
301
|
+
deliver) → the whole tick is end-to-end testable without a real ps/model/signal.
|
|
302
|
+
- **Dangerous direction:** driving the funnel toward a kill it shouldn't. Guarded: a kill is
|
|
303
|
+
attempted ONLY when verdict==='kill' && floor.permitted && breaker-not-tripped; and even then
|
|
304
|
+
the funnel is watch-only unless armed (so the orchestrator can never cause a signal the funnel
|
|
305
|
+
wouldn't). Every non-killed sustained hog (leave / veto / decider-unavailable / breaker /
|
|
306
|
+
would-kill / over-cap) is SURFACED (§4 observability floor — the model can't silence a hog).
|
|
307
|
+
- **Watch-only rides through:** in the shipped dryRun state the funnel returns would-kill → no
|
|
308
|
+
signal → the tick produces would-kill records + observability notices, kills NOTHING.
|
|
309
|
+
- **Signal vs authority:** pure control flow composing the reviewed modules. Multi-machine:
|
|
310
|
+
machine-local. Rollback: delete; the server wiring (later) consumes it.
|
|
311
|
+
- **Tests:** 6 — watch-only hog→would-kill NO SIGNAL + surfaced; armed hog→killed + kill notice;
|
|
312
|
+
model-leave→alert-only+surfaced; decider-unavailable→notice; floor-veto→alert-only+notice;
|
|
313
|
+
idle→nothing.
|
|
314
|
+
|
|
315
|
+
### Slice 14 — guard-posture status (pure §8 honesty rule)
|
|
316
|
+
Files: `src/monitoring/ExternalHogGuardStatus.ts` (`externalHogEffectiveState`),
|
|
317
|
+
`tests/unit/external-hog-guard-status.test.ts` (5 tests).
|
|
318
|
+
- **What it is:** the pure mapping of live state → a `GuardEffectiveState`. Enforces the §8
|
|
319
|
+
honesty rule: `on-confirmed` ONLY when actually kill-capable (enabled && !dryRun &&
|
|
320
|
+
marker-valid); the reachable config.dryRun:false + marker-absent state reads `on-dry-run`
|
|
321
|
+
(armed-pending mapped to on-dry-run in v1), never on-confirmed; a dead sampler → `on-stale`.
|
|
322
|
+
- **Signal vs authority:** a STATUS signal — it never kills or gates. Not kill-decision logic,
|
|
323
|
+
so the safety-critical second-pass does not apply; the honesty risk (a false on-confirmed) is
|
|
324
|
+
covered by the exhaustive branch tests. Multi-machine: pure (per-machine posture).
|
|
325
|
+
- **Rollback:** delete; the guard-posture wiring (server slice) consumes it.
|
|
326
|
+
- **Tests:** 5 — off/on-confirmed/honesty-on-dry-run/dryRun-soak/sampler-dead-on-stale.
|
|
327
|
+
|
|
328
|
+
### Slice 15 — the composition shell (`ExternalHogSentinel`)
|
|
329
|
+
Files: `src/monitoring/ExternalHogSentinel.ts` (`ExternalHogSentinel`, `buildProcTree`),
|
|
330
|
+
`src/monitoring/ExternalHogScanTick.ts` (additive: `ScanOutcome` now carries `ledgerKey`/`classId`),
|
|
331
|
+
`tests/unit/external-hog-sentinel.test.ts` (5 tests).
|
|
332
|
+
- **What it is:** the ADAPTER LAYER that turns the reviewed pure modules into a live, tickable
|
|
333
|
+
monitor. It adds NO kill decision — every tick delegates the whole decision to the reviewed
|
|
334
|
+
`runScanTick`. Its only jobs: (a) hold cross-tick state the pure orchestrator cannot (sampler
|
|
335
|
+
baseline + kill ledger + per-signature deferral count); (b) bridge the async real reads (ps
|
|
336
|
+
spawn / owned-pid resolve) into the sync closures the orchestrator expects, by reading a
|
|
337
|
+
snapshot BEFORE the tick and closing over it; (c) persist the per-signature deferral count so
|
|
338
|
+
`maxKillDeferrals` actually bounds ACROSS ticks (proven behaviorally: an open-workspace-file
|
|
339
|
+
hog defers each scan under the cap, then proceeds to SIGKILL at the cap); (d) deliver notices
|
|
340
|
+
every tick; (e) expose the honest §8 `status()`.
|
|
341
|
+
- **The orchestrator amendment:** `ScanOutcome` gained `ledgerKey`+`classId` (additive — better
|
|
342
|
+
audit + lets the shell persist deferral counts without re-deriving identity). Not a logic
|
|
343
|
+
change; all 7 scan-tick tests stay green.
|
|
344
|
+
- **Watch-only ride-through:** in the shipped dryRun state a tick produces would-kill records +
|
|
345
|
+
§4 observability notices and signals NOTHING (test: `on-dry-run`, zero signals).
|
|
346
|
+
- **Fail-safe:** a read failure degrades to an empty tick (sampler heartbeat does not advance →
|
|
347
|
+
eventually on-stale); `auditTick` is best-effort (a write failure never breaks a tick).
|
|
348
|
+
- **Over-block / under-block:** none new — the shell cannot widen a kill (it only forwards to the
|
|
349
|
+
funnel, which is watch-only + floor-vetoed + arm-gated). The one risk it introduces is
|
|
350
|
+
UNBOUNDED memory in the deferral map; bounded by `deferralMapMax` (default 128, oldest-pruned)
|
|
351
|
+
AND by terminal-clear (a resolved/killed/gone signature is deleted).
|
|
352
|
+
- **Signal vs authority:** pure forwarding — the shell holds NO authority the modules below don't.
|
|
353
|
+
- **Multi-machine:** machine-local BY DESIGN — a process hog + its ps table + owned pids are
|
|
354
|
+
physical to ONE host's process table; the kill is a `process.kill` on THIS machine. `physical-
|
|
355
|
+
credential-locality`-class locality (a host's live process table is hardware/OS-bound). Posture
|
|
356
|
+
per §7 in the spec. No cross-machine state.
|
|
357
|
+
- **Rollback:** delete the file; the AgentServer construction (next slice) is what wires it in.
|
|
358
|
+
- **Tests:** 5 — buildProcTree, watch-only ride-through + delivery-every-tick + on-dry-run,
|
|
359
|
+
armed→SIGKILL + on-confirmed, cross-tick deferral→SIGKILL-at-cap, sampler-dead→on-stale.
|
|
360
|
+
|
|
361
|
+
### Slice 16 — the N-window sustained-CPU confirmation (§1 anti-spike)
|
|
362
|
+
Files: `src/monitoring/ExternalHogSustained.ts` (`advanceSustained`/`isSustained`/`candidateSignature`),
|
|
363
|
+
`src/monitoring/ExternalHogScanTick.ts` (orchestrator amendment: ScanState.sustained,
|
|
364
|
+
ScanOpts.sustainedSampleCount, advance-after-candidacy, AND into sustainedHighCpu),
|
|
365
|
+
`src/monitoring/ExternalHogSentinel.ts` (state init gains sustained),
|
|
366
|
+
`tests/unit/external-hog-sustained.test.ts` (9), scan-tick +1 anti-spike test, sentinel opts.
|
|
367
|
+
- **What it is + why:** the spec §1 requires sustainedHighCpu to be an N-window confirmation
|
|
368
|
+
(`sustainedSampleCount:3`) — a kill must NOT fire on a single-window CPU spike (a compile, a GC
|
|
369
|
+
pause). The sampler only produces SINGLE-window candidates; this tracker holds the per-signature
|
|
370
|
+
CONSECUTIVE-window streak and the orchestrator sets sustainedHighCpu authoritatively =
|
|
371
|
+
(fact-builder single-window read) AND (streak ≥ N). A one-window spike (streak < N) is forced to
|
|
372
|
+
sustainedHighCpu:false → the floor's HARD VETO downgrades it to alert — never a kill.
|
|
373
|
+
- **Where it lives (architecture):** the streak is stage-2 decision state that ONLY the
|
|
374
|
+
orchestrator can coordinate — it alone has both the full tick candidate set (to advance streaks)
|
|
375
|
+
AND the per-candidate fact call (to apply the result). So it is threaded through ScanState, not
|
|
376
|
+
hidden in the adapter. Advanced ONCE per tick right after candidacy.
|
|
377
|
+
- **Safe direction everywhere:** absence resets the streak (strict consecutive — a one-window dip
|
|
378
|
+
from ps quantization re-accumulates rather than shortening the path to a kill); a failed/empty
|
|
379
|
+
parse resets EVERY streak (fail toward not-sustained); a bad N (≤0/non-finite) → isSustained
|
|
380
|
+
false (a misconfigured N can never let a spike qualify). Bounded: the next streak map is rebuilt
|
|
381
|
+
ONLY from this tick's candidates (≤ live candidate count).
|
|
382
|
+
- **Over-block:** a genuine hog that dips below threshold for one noisy window is delayed N more
|
|
383
|
+
windows (≈90s at defaults) — an acceptable, deliberately-conservative delay, never a missed kill
|
|
384
|
+
(it re-qualifies). **Under-block:** none — the gate only ever ADDS a precondition to a kill.
|
|
385
|
+
- **Signal vs authority:** pure predicate feeding the floor's veto; holds no authority itself.
|
|
386
|
+
- **Multi-machine:** machine-local BY DESIGN (a host's process CPU history is physical to one
|
|
387
|
+
machine; `physical-credential-locality`-class). No cross-machine state.
|
|
388
|
+
- **Rollback:** revert the amendment (sustainedHighCpu falls back to the single-window read) +
|
|
389
|
+
delete the tracker. **Tests:** 9 tracker + 1 orchestrator anti-spike (single-window+N=2 → veto,
|
|
390
|
+
no kill); all 8 scan-tick + 6 sentinel stay green (N=1 preserves single-window behavior there).
|
|
391
|
+
|
|
392
|
+
### Slice 17 — the armed-marker persistence store (the live-kill authorization file)
|
|
393
|
+
Files: `src/monitoring/ExternalHogArmStore.ts` (`loadArmState`/`armStore`/`disarmStore`),
|
|
394
|
+
`tests/unit/external-hog-arm-store.test.ts` (9 tests).
|
|
395
|
+
- **What it is:** the durable 0600 file behind the reviewed marker VALIDATORS — the thing the PIN
|
|
396
|
+
arm route writes, the disarm route bumps, and the poll loop reads. Upholds the two load-bearing
|
|
397
|
+
properties by how it mutates epochs: ARM raises armEpoch strictly above both prior armEpoch AND
|
|
398
|
+
lastDisarmEpoch (a fresh arm always wins); DISARM raises lastDisarmEpoch ≥ current armEpoch (the
|
|
399
|
+
marker becomes invalid — a disarm can NEVER be silently un-done; returning to live-kill always
|
|
400
|
+
needs a fresh PIN arm).
|
|
401
|
+
- **Fail-closed reads:** missing / unparseable / wrong-shape / non-string-hash / corrupt-epoch →
|
|
402
|
+
`{marker:null, lastDisarmEpoch:0}` (marker null → not armed regardless of any epoch). No corrupt
|
|
403
|
+
shape yields a marker `canKillLive` accepts. `coerceMarker` rebuilds an allowlisted object +
|
|
404
|
+
strictly string-checks every snapshot hash (no prototype-pollution).
|
|
405
|
+
- **Durable atomic writes:** tmp → fsync → rename (0600). The fsync closes the Phase-5 residual —
|
|
406
|
+
a power loss right after a DISARM must not revert to armed (the one safety-critical direction).
|
|
407
|
+
- **Over/under-block:** none — it never decides a kill; it only persists the authorization the PIN
|
|
408
|
+
route grants. **Signal vs authority:** the PIN route is the authority; this is its durable record.
|
|
409
|
+
- **Multi-machine:** machine-local BY DESIGN — the arm is a physical, per-machine operator consent
|
|
410
|
+
to kill on THIS host's process table (`physical-credential-locality`-class); it MUST NOT
|
|
411
|
+
replicate (a PIN arm on one machine can never authorize a kill on another). No cross-machine state.
|
|
412
|
+
- **Rollback:** delete the file + module; with no marker the feature is watch-only regardless.
|
|
413
|
+
- **Tests:** 9 — fresh/corrupt/wrong-shape fail-closed, arm authorizes exactly the consented class,
|
|
414
|
+
new/broadened class not armed, 0600 mode, disarm invalidates + monotonic + idempotent, and
|
|
415
|
+
reboot-boots-unarmed (live config alone never re-arms).
|
|
416
|
+
|
|
417
|
+
### Slice 18 — the deterministic fact + identity builder (stage-2 derivation)
|
|
418
|
+
Files: `src/monitoring/ExternalHogFactBuilder.ts` (`buildFacts`/`buildIdentity`/`deriveOwnerAppRunning`/
|
|
419
|
+
`parseParentPid`/`lstartToEpochMs`), `src/monitoring/ExternalHogFloor.ts` (DOC-ONLY: fixed an
|
|
420
|
+
INVERTED comment on `ownerAppRunning`), `tests/unit/external-hog-fact-builder.test.ts` (18 tests).
|
|
421
|
+
- **What it is:** the PURE stage-2 derivation that turns a candidate's proc row + FULL argv (fetched
|
|
422
|
+
off-loop by the adapter) into the `ExternalHogFacts` the floor evaluates and the identity
|
|
423
|
+
(classId/commandHash/ledgerKey) the ledger + funnel key on. No process spawn, no clock — the
|
|
424
|
+
impure edges (ps -o args=, launchctl, geteuid) are passed IN, so every derivation is testable.
|
|
425
|
+
- **The load-bearing derivation — `ownerAppRunning` polarity (§ round-6/round-8):** `true` = the
|
|
426
|
+
specific `--parentPid` owner is alive OR cannot be established → floor VETO; `false` = that
|
|
427
|
+
parent is dead (start-time-verified) → kill-eligible. Round-8 reused-pid rule: parent-pid absent
|
|
428
|
+
→ dead; parent-pid present but its lstart is LATER than the child's → pid reused, real parent
|
|
429
|
+
dead; parent older-or-equal (or start-times un-orderable) → assume live → veto. EVERY
|
|
430
|
+
un-establishable branch fails toward `true` (veto). Validated by running the built facts through
|
|
431
|
+
the REAL floor (a genuine orphaned exthost PERMITS; root/launchctl/non-sustained VETO).
|
|
432
|
+
- **The lstart ordering parse:** the proc-table parser keeps `startTime` an OPAQUE equality token
|
|
433
|
+
by design; this slice adds a CONFINED, fail-safe `lstartToEpochMs` used ONLY for the one
|
|
434
|
+
reused-pid ordering comparison (parse ambiguity → null → conservative veto). Does not change the
|
|
435
|
+
parser's opaque-token contract.
|
|
436
|
+
- **Floor doc-bug fix (doc-only, no logic change):** the `ownerAppRunning` field comment in
|
|
437
|
+
ExternalHogFloor.ts read "parent is dead → true", the INVERSE of the floor's own step-5 logic
|
|
438
|
+
(`ownerAppRunning` true → veto). Corrected to match the authoritative logic + the spec.
|
|
439
|
+
- **Identity:** `buildIdentity` is allowlist-gated (outside the code-defined allowlist → null → not
|
|
440
|
+
kill-eligible, surfaced not killed); the command-hash STRIPS the volatile `--parentPid` so the
|
|
441
|
+
P19 breaker counts respawns of the SAME command (proven: hash stable across a changed parentPid).
|
|
442
|
+
- **Over/under-block:** over-vetoes on uncertainty (the safe direction); the only under-block risk
|
|
443
|
+
(a reused-then-immediately-hog parent) is the documented identity limit, fail-safe. **Signal vs
|
|
444
|
+
authority:** pure derivation feeding the floor; holds no authority. **Multi-machine:**
|
|
445
|
+
machine-local BY DESIGN (a process tree + argv are physical to one host; `physical-credential-
|
|
446
|
+
locality`). **Rollback:** delete the module (the real adapter's factsFor would inline it).
|
|
447
|
+
- **Tests:** 18 — parentPid parse, lstart ordering + fail-safe null, all 5 ownerAppRunning
|
|
448
|
+
branches, floor-validated permit/veto for orphan/root/launchctl/non-sustained, allowlist-gated +
|
|
449
|
+
stable-hash identity.
|
|
450
|
+
|
|
451
|
+
### Slice 19 — the classifier prompt builder (injection-hardening boundary)
|
|
452
|
+
Files: `src/monitoring/ExternalHogClassifierPrompt.ts` (`buildClassifierPrompt`),
|
|
453
|
+
`tests/unit/external-hog-classifier-prompt.test.ts` (6 tests).
|
|
454
|
+
- **What it is:** the PURE composer of the kill/leave/alert prompt fed to the zombie-classify
|
|
455
|
+
model. Carries the envelope-wrapped DERIVED facts (matched class, ownerAppRunning,
|
|
456
|
+
sustainedHighCpu, launchctl-label, same-uid boolean) + the attacker-controllable name/argv
|
|
457
|
+
wrapped as explicit untrusted data, and demands a strict `{"action":"kill|leave|alert"}` verdict.
|
|
458
|
+
- **Two spec §5 security properties enforced here:** (1) the raw (pid, start-time, command-hash)
|
|
459
|
+
IDENTITY TUPLE is NEVER in the prompt (round-8 — denies an injection payload a concrete target
|
|
460
|
+
to name); (2) the name/argv are wrapped in a `<untrusted-process-data>` envelope with a
|
|
461
|
+
treat-as-data instruction, and the embedded values are delimiter-stripped (a process forging the
|
|
462
|
+
close-tag can't break out) + length-clamped (no unbounded prompt growth). Unit-asserted.
|
|
463
|
+
- **Signal vs authority — why no second-pass:** the prompt holds NO kill authority. The model's
|
|
464
|
+
verdict is SUBTRACTIVE (it can only SPARE an in-envelope process; the floor's two-key AND means
|
|
465
|
+
a kill still needs `floor.permitted`), so a prompt bug (or even a fully-successful injection) can
|
|
466
|
+
only cause a false-LEAVE / false-ALERT or a kill WITHIN the allowlist envelope the attacker
|
|
467
|
+
crafted — NEVER a wrong-kill outside the floor. This is effectiveness, not kill-safety; the
|
|
468
|
+
security properties are unit-asserted. (Consistent with the coalescer slice — not-kill-logic.)
|
|
469
|
+
- **Over/under-block:** a poor prompt lowers effectiveness (more false-leaves), never safety.
|
|
470
|
+
**Multi-machine:** pure string builder, no state. **Rollback:** delete; the adapter would inline
|
|
471
|
+
a prompt. **Tests:** 6 — derived facts present, strict verdict demanded, identity-tuple excluded,
|
|
472
|
+
untrusted envelope + forge-resistance + length clamp.
|
|
473
|
+
|
|
474
|
+
### Slice 20 — the real-I/O adapter factory (the glue that makes it run)
|
|
475
|
+
Files: `src/monitoring/ExternalHogRealAdapters.ts` (`createExternalHogAdapters` + pure lsof/launchctl
|
|
476
|
+
parsers), `src/monitoring/ExternalHogKillFunnel.ts` + `ExternalHogScanTick.ts` + `ExternalHogSentinel.ts`
|
|
477
|
+
(async-ified factsFor / reReadFacts / hasOpenWritableWorkspaceFile), `src/monitoring/ExternalHogFloor.ts`
|
|
478
|
+
(additive `classRuleSources` export), `tests/unit/external-hog-real-adapters.test.ts` (13 tests).
|
|
479
|
+
- **What it is:** the impure edge binding the reviewed pure modules to the real OS. Every raw
|
|
480
|
+
side-effect (spawn ps/launchctl/lsof off-loop, process.kill, the model call, raiseAttention,
|
|
481
|
+
clock, arm-file read) is a single INJECTED primitive → the wiring is unit-testable with fakes and
|
|
482
|
+
NO real process is spawned/signalled in a test. Holds NO kill decision — it READS the OS to
|
|
483
|
+
produce facts and EXECUTES the signals the reviewed funnel decides on.
|
|
484
|
+
- **Async-ification (kill-path files):** factsFor (orchestrator) + reReadFacts/hasOpenWritableWorkspaceFile
|
|
485
|
+
(funnel) genuinely need async I/O (a live ps+argv / lsof read). Widened to accept a Promise +
|
|
486
|
+
`await` — NO decision change; every existing sync test fake still works via await; all 25
|
|
487
|
+
prior funnel/scan-tick/sentinel tests stay green.
|
|
488
|
+
- **The §4.5 kill-time CPU RE-CONFIRM (a gap I caught + fixed mid-build):** reReadFacts must not
|
|
489
|
+
assume the candidate is still a hog. Added a `cpuCoresOver(pid, windowMs)` micro-probe primitive;
|
|
490
|
+
reReadFacts sets sustainedHighCpu from a FRESH sample — a below-threshold OR null reading →
|
|
491
|
+
sustainedHighCpu:false → the floor re-check ABORTS the kill (the "went idle since classify" guard).
|
|
492
|
+
Tested (still-pinning → true; idle → false; null → false).
|
|
493
|
+
- **classRuleSources (floor, additive):** the SINGLE source of truth for a class's content-hash,
|
|
494
|
+
so the arm route's snapshot and the funnel's `currentClassContentHash` always agree (tested).
|
|
495
|
+
- **Fail-safe directions:** ps fail → empty table (→ on-stale); argv null → skip candidate; lsof
|
|
496
|
+
error → DEFER (bounded); launchctl error → empty set (floor-bounded, feature keeps working);
|
|
497
|
+
cpu null → not-sustained (abort); attention delivery best-effort (never throws into a tick).
|
|
498
|
+
- **Signal vs authority:** pure forwarding — no authority beyond the modules it wires.
|
|
499
|
+
**Multi-machine:** machine-local BY DESIGN (spawns/signals THIS host's processes). **Rollback:**
|
|
500
|
+
delete the factory; the server construction (next slice) is what instantiates it.
|
|
501
|
+
- **Tests:** 13 — pure lsof/launchctl parsers, readProcTable/ownedRefs/factsFor/identityFor/classify
|
|
502
|
+
wiring, armStatus composition, deliverNotices, and the §4.5 CPU re-confirm + arm-scope hash agreement.
|
|
503
|
+
|
|
504
|
+
### Slice 21 — migration parity (existing agents get the dev-gated config on update)
|
|
505
|
+
Files: `src/core/PostUpdateMigrator.ts` (`migrateConfigExternalHogSentinelDevGate` + its registration
|
|
506
|
+
in `migrateConfig`), `tests/unit/PostUpdateMigrator-externalHogSentinel.test.ts` (7 tests).
|
|
507
|
+
- **What it is:** the Migration Parity Standard for the config. On update, an EXISTING agent gets the
|
|
508
|
+
`monitoring.externalHogSentinel` DARK defaults block (dryRun:true + kill-gate knobs, `enabled`
|
|
509
|
+
OMITTED) via ConfigDefaults + applyDefaults add-missing — no add-migration needed. The migrator
|
|
510
|
+
handles the ONE case applyDefaults can't: it STRIPS a default-shaped `enabled:false` (the #1001
|
|
511
|
+
force-dark mechanism that would dark even a dev agent), so `resolveDevAgentGate` resolves it
|
|
512
|
+
live-on-dev / dark-fleet. Mirrors the credentialRepointing / playwrightRegistry precedents exactly.
|
|
513
|
+
- **Never clobbers:** an explicit `enabled:true` (an operator fleet-flip) is PRESERVED; the `dryRun`
|
|
514
|
+
canary is left untouched. Existence-checked + idempotent.
|
|
515
|
+
- **Over/under-block:** none — it only removes a default-shaped literal that would misconfigure the
|
|
516
|
+
gate. **Signal vs authority:** a config migration, no runtime decision. **Multi-machine:**
|
|
517
|
+
machine-local config edit (each machine migrates its own config). **Rollback:** the strip is
|
|
518
|
+
idempotent; re-adding `enabled:false` to config restores the old shape.
|
|
519
|
+
- **Second-pass not-required:** a config-defaults migration (not kill-logic), purely additive,
|
|
520
|
+
mirroring two reviewed dev-gate precedents; the pure strip predicate + the through-migrateConfig
|
|
521
|
+
behavior are unit-asserted. **Tests:** 7 — strip predicate (3), block-install / strip / no-clobber
|
|
522
|
+
/ idempotent through the real migrateConfig (4).
|
|
523
|
+
- **CLAUDE.md agent-awareness (Agent Awareness Standard) is a SEPARATE upcoming slice** (generateClaudeMd
|
|
524
|
+
+ migrateClaudeMd). <!-- tracked: CMT-1901 -->
|
|
525
|
+
|
|
526
|
+
### Slice 22 — the HTTP routes (status + PIN-gated arm + disarm)
|
|
527
|
+
Files: `src/server/routes.ts` (RouteContext.externalHogSentinel + GET /external-hog, POST
|
|
528
|
+
/external-hog/arm, POST /external-hog/disarm), `tests/integration/external-hog-routes.test.ts` (6).
|
|
529
|
+
- **What it is:** the operator surface. GET /external-hog (Bearer, read-only) returns
|
|
530
|
+
sentinel.status() + the durable arm state (503 when the sentinel is dark). POST /external-hog/arm
|
|
531
|
+
(PIN-gated via checkMandatePin — a Bearer token cannot arm a real kill; Know Your Principal)
|
|
532
|
+
builds the per-class content-hash snapshot from EXTERNAL_HOG_ALLOWLIST via classRuleSources +
|
|
533
|
+
classContentHash and calls armStore. POST /external-hog/disarm (Bearer — the SAFE direction, no
|
|
534
|
+
PIN) calls disarmStore.
|
|
535
|
+
- **Why PIN on arm but not disarm:** arming turns ON an irreversible action (a real kill) → minimum
|
|
536
|
+
rung 1 (PIN). Disarming returns to watch-only (the safe direction) → Bearer is sufficient.
|
|
537
|
+
- **Snapshot agreement:** the route arms every allowlist class with the SAME hash the funnel's
|
|
538
|
+
currentClassContentHash re-checks (both derive from classRuleSources) — so a legitimately-armed
|
|
539
|
+
class is recognized, and a matcher change forces a re-arm. Thin wiring over the reviewed
|
|
540
|
+
armStore/disarmStore epoch machinery.
|
|
541
|
+
- **Over/under-block:** GET emits only the arm METADATA (armEpoch, armedAt, class NAMES) — never a
|
|
542
|
+
secret. **Signal vs authority:** the PIN + the arm-store epochs are the authority; the route is
|
|
543
|
+
the surface. **Multi-machine:** machine-local BY DESIGN (a PIN arm authorizes a kill on THIS
|
|
544
|
+
host's process table; the marker must not replicate). **Rollback:** the routes 503/no-op when the
|
|
545
|
+
sentinel is unwired.
|
|
546
|
+
- **Tests:** 6 (Tier-2 integration over the real HTTP pipeline) — 503-when-dark, status shape,
|
|
547
|
+
arm-403-without-PIN, arm-403-wrong-PIN, arm-200-mints-epoch-1, and the full arm→disarm→re-arm
|
|
548
|
+
(epoch 2) lifecycle through the routes + the durable marker file.
|
|
549
|
+
|
|
550
|
+
### Slice 23 — the server primitives (the §4.5 CPU probe + the primitive factory)
|
|
551
|
+
Files: `src/monitoring/ExternalHogServerPrimitives.ts` (`makeCpuCoresOver` + `createExternalHogServerPrimitives`
|
|
552
|
+
+ `parseTmuxPanePids`), `tests/unit/external-hog-server-primitives.test.ts` (7 tests).
|
|
553
|
+
- **What it is:** builds the real-OS ExternalHogPrimitives the server injects into the factory —
|
|
554
|
+
kept OUT of the giant server command so the one non-trivial composed primitive (the §4.5 kill-time
|
|
555
|
+
CPU micro-probe) is unit-testable. Thin over injected low-level deps (async exec, process.kill,
|
|
556
|
+
monotonic clock, sleep, intelligence.evaluate, raiseAttention, resolved config, loadArmState).
|
|
557
|
+
- **The §4.5 probe (`makeCpuCoresOver`, kill-GATING):** samples a pid's cumulative cputime TWICE over
|
|
558
|
+
windowMs (monotonic Δwall) and resolves core-equivalents via the REVIEWED computeCoreEquivalents
|
|
559
|
+
(whose symmetric small/large-Δwall guards — the slice-4 fix — already block ps-quantization
|
|
560
|
+
false-highs). Fail-safe to null (→ the reviewed reReadFacts aborts the kill) on: pid gone, ps
|
|
561
|
+
unreadable/unparseable, pid-REUSED mid-window (startTime changed), or an UNKNOWN delta.
|
|
562
|
+
- **Over-block / under-block:** the false-HIGH direction (a non-hog reading as sustained → wrong
|
|
563
|
+
kill) is guarded by computeCoreEquivalents + tested (idle → ~0 cores, not high); the false-low
|
|
564
|
+
direction just spares (safe). **Signal vs authority:** a measurement primitive — no authority.
|
|
565
|
+
**Multi-machine:** machine-local (samples THIS host's processes). **Rollback:** delete; the
|
|
566
|
+
construction slice would inline the primitives.
|
|
567
|
+
- **Tests:** 7 — still-pinning → ~2 cores, idle → ~0, vanished → null, pid-reused → null, exec-throw
|
|
568
|
+
→ null, tmux-pane parse, and the factory assembly (config/loadArm/serverPid/tmux pass-through).
|
|
569
|
+
|
|
570
|
+
### Slice 24 — server construction (make it alive) + guard registration
|
|
571
|
+
Files: `src/commands/server.ts` (construct primitives → adapters → sentinel; guardRegistry.register;
|
|
572
|
+
the dev-gate-gated interval; pass to AgentServer), `src/server/AgentServer.ts` (the externalHogSentinel
|
|
573
|
+
option → ctx), `src/monitoring/guardManifest.ts` (expectRuntime false→true), `src/monitoring/
|
|
574
|
+
ExternalHogSentinel.ts` (guardRuntimeStatus getter), `tests/e2e/external-hog-routes-alive.test.ts` (4).
|
|
575
|
+
- **What it is:** the production composition root. Resolves the developmentAgent gate
|
|
576
|
+
(resolveDevAgentGate) → live-on-dev / dark-fleet; builds the real primitives (execFileAsync
|
|
577
|
+
ps/launchctl/lsof w/ 15s timeout + 8MB cap, process.kill, sharedIntelligence.evaluate,
|
|
578
|
+
telegram.createAttentionItem, resolved config, loadArmState, the §4.5 probe); creates the adapters
|
|
579
|
+
+ the ExternalHogSentinel; ALWAYS registers the /guards runtime getter (honest posture even when
|
|
580
|
+
dark); and starts the scan interval ONLY when the dev-gate resolves enabled (unref'd so it never
|
|
581
|
+
holds the process open). Construction is TRY-guarded → a failure disables the feature this boot,
|
|
582
|
+
never crashes the server.
|
|
583
|
+
- **The interval is dev-gate-gated:** when dark (fleet), no interval → NO tick → NO kill, ever. The
|
|
584
|
+
dryRun canary flows to the sentinel's config() so even a live-on-dev tick is watch-only until a
|
|
585
|
+
PIN arm. `guardRuntimeStatus().lastTickAt` = the sampler heartbeat, so /guards reads on-stale when
|
|
586
|
+
blind (never a false on-confirmed).
|
|
587
|
+
- **guardManifest expectRuntime false→true:** now that the getter is registered at boot, /guards
|
|
588
|
+
expects the runtime report (an un-registered expectRuntime would manufacture a phantom `missing`).
|
|
589
|
+
- **Over/under-block:** none new — the construction only WIRES reviewed pieces; the kill decision is
|
|
590
|
+
entirely below it. **Signal vs authority:** composition, no authority. **Multi-machine:**
|
|
591
|
+
machine-local BY DESIGN (each machine constructs its own sentinel over ITS process table; nothing
|
|
592
|
+
replicates). **Rollback:** the try-guard + the dev-gate + the omitted config `enabled` mean the
|
|
593
|
+
feature is dark-by-default; removing the construction block reverts cleanly.
|
|
594
|
+
- **Tests:** 4 (Tier-3 E2E "feature is alive" over the real AgentServer) — GET 200 with a live status
|
|
595
|
+
(a benign tick advanced the sampler → on-dry-run), the arm→disarm→re-arm epoch-2 lifecycle
|
|
596
|
+
end-to-end, dark → 503, and auth (Bearer required, arm needs the PIN not just Bearer).
|
|
597
|
+
|
|
598
|
+
### Slice 25 — CLAUDE.md agent-awareness (Agent Awareness Standard)
|
|
599
|
+
Files: `src/core/PostUpdateMigrator.ts` (`EXTERNAL_HOG_CLAUDEMD_SECTION` + the migrateClaudeMd
|
|
600
|
+
append), `src/scaffold/templates.ts` (import + concat into generateClaudeMd), the migration test
|
|
601
|
+
(+3 CLAUDE.md tests).
|
|
602
|
+
- **What it is:** the Agent Awareness Standard — an agent that doesn't know about a capability
|
|
603
|
+
effectively doesn't have it. Adds a CLAUDE.md section teaching the agent the GET /external-hog
|
|
604
|
+
status route, the PIN-gated arm + Bearer disarm routes, the two-key `floor_pass &&
|
|
605
|
+
classifier==='kill'` rule, the watch-only/PIN-arm posture, and the proactive triggers ("what's
|
|
606
|
+
pinning my CPU?", "why did an editor helper get killed?", "why is it only watching?").
|
|
607
|
+
- **Migration parity (existing agents):** migrateClaudeMd appends the section content-sniffed on
|
|
608
|
+
'External-Hog Zombie Auto-Kill Sentinel' (idempotent, preserves prior content). generateClaudeMd
|
|
609
|
+
(templates.ts) emits the SAME section so fresh installs get it too (parity between the two sources).
|
|
610
|
+
- **The PIN honesty rule is IN the section:** "NEVER ask the user to paste the PIN into chat — point
|
|
611
|
+
them at the dashboard" (Operators-act-in-taps).
|
|
612
|
+
- **Not runtime behavior:** documentation/awareness only. **Second-pass not-required** (no decision
|
|
613
|
+
logic). **Rollback:** the content-sniff guard makes re-running a no-op; removing the section
|
|
614
|
+
function + the two callsites reverts. **Tests:** +4 — the section is added with the key routes +
|
|
615
|
+
posture + the two-key rule; idempotent + content-preserving; and the generateClaudeMd template
|
|
616
|
+
emits it (fresh-install parity).
|
|
617
|
+
|
|
618
|
+
### Slice 26 — CI ratchet fixes (route-prefix classification + bench-coverage decision)
|
|
619
|
+
Files: `src/server/CapabilityIndex.ts` (INTERNAL_PREFIXES += external-hog), `src/data/llmBenchCoverage.ts`
|
|
620
|
+
(ExternalHogClassifier bench decision). Two registry ratchets CI caught (the only 2 red tests among
|
|
621
|
+
7448; every other test + all my local suites were green):
|
|
622
|
+
- **capabilities-discoverability:** the `/external-hog` route prefix was registered in routes.ts but
|
|
623
|
+
not classified. Added it to `INTERNAL_PREFIXES` — agent-read operational observability (status) +
|
|
624
|
+
a PIN-gated operator action (arm/disarm), like `/green-pr-automerge`; dev-gated dark, 503 on the
|
|
625
|
+
fleet, surfaced via the CLAUDE.md agent-awareness section (not a user-invokable capability).
|
|
626
|
+
- **llm-bench-coverage-ratchet:** `ExternalHogClassifier` (a new sentinel-category LLM component)
|
|
627
|
+
needed a bench-coverage decision. Added `{ task: 'zombie-classify' }` — the operator-approved
|
|
628
|
+
benchmark that IS this classifier's bench task (measures false-leave/false-alert EFFECTIVENESS;
|
|
629
|
+
kill-SAFETY is carried entirely by the deterministic floor, so it never gates a kill). The task
|
|
630
|
+
battery lives in the research tree; neither ratchet checks the task file on disk.
|
|
631
|
+
- **Over/under-block:** none — registry/ratchet entries. **Signal vs authority:** no runtime
|
|
632
|
+
decision. **Rollback:** remove the two entries. **Tests:** both ratchet suites green (151 tests).
|
|
633
|
+
|
|
634
|
+
## Phase 5 — Second-pass review
|
|
635
|
+
|
|
636
|
+
### Slice 16 Phase-5 verdict — defect found + fixed → CONCUR
|
|
637
|
+
An independent reviewer read all six kill-path files and traced A–E:
|
|
638
|
+
- **A (false-sustained):** handled — `candidateSignature` is byte-identical to the sampler's
|
|
639
|
+
`idKey`, the streak accrues to the right process, `startTime` defeats pid-reuse, duplicates
|
|
640
|
+
dedupe.
|
|
641
|
+
- **B (AND-composition) — REAL DEFECT FOUND + FIXED:** `rawFacts.sustainedHighCpu && sustained`
|
|
642
|
+
could LAUNDER a degraded truthy non-boolean (`1`) into boolean `true`, defeating the floor's
|
|
643
|
+
round-11 `typeof !== 'boolean' → field-unknown` veto in the kill-PERMITTING direction. FIXED to
|
|
644
|
+
the reviewer's exact prescription: `rawFacts.sustainedHighCpu === true ? sustained :
|
|
645
|
+
rawFacts.sustainedHighCpu` — the N-window gate applies ONLY to a genuine boolean `true`; every
|
|
646
|
+
other value (false / 1 / undefined / null) is PRESERVED verbatim so the floor still vetoes it.
|
|
647
|
+
Added a regression test (a `sustainedHighCpu:1` fact + full streak → NO kill, alert-only,
|
|
648
|
+
floor-veto-downgrade). Reviewer RE-CONFIRMED: "Category B is fully closed; the regression test
|
|
649
|
+
pins it. Preserving `false` is identical to forcing it — introduces nothing."
|
|
650
|
+
- **C/D/E:** handled — bad-N fail-closed, empty-parse reset, bounded map, correct state threading,
|
|
651
|
+
and the funnel's live re-read composes subtractively (no gate bypass).
|
|
652
|
+
|
|
653
|
+
Verdict: **Concur with the review** (after the category-B fix).
|
|
654
|
+
|
|
655
|
+
### Slice 17 Phase-5 verdict — CONCUR
|
|
656
|
+
An independent reviewer traced all five risks against the validators and found no safety defect:
|
|
657
|
+
no corrupt/crafted/torn-write path yields a marker `canKillLive` accepts.
|
|
658
|
+
- **A disarm durability:** `lastDisarmEpoch = max(cur, markerEpoch)` reads the CURRENT marker epoch
|
|
659
|
+
→ however high armEpoch, the marker reads invalid; a failed rename leaves the OLD state and
|
|
660
|
+
throws (never a silent partial disarm).
|
|
661
|
+
- **B fail-closed:** any corrupt/NaN/negative/missing lastDisarmEpoch collapses the read to
|
|
662
|
+
not-armed.
|
|
663
|
+
- **C monotonicity:** `max(prior, lastDisarm)+1` is strictly above both; 2^53 float precision fails
|
|
664
|
+
SAFE (won't arm).
|
|
665
|
+
- **D atomicity:** tmp+rename atomic; readers only open the main file.
|
|
666
|
+
- **E laundering:** coerceMarker rebuilds an allowlisted object, rejects non-string snapshot values;
|
|
667
|
+
`__proto__` keys can't pollute.
|
|
668
|
+
- **Residual (non-blocking, now CLOSED):** the reviewer noted writeState lacked fsync (a power loss
|
|
669
|
+
after a disarm could revert to a PREVIOUSLY-authorized arm — never new/widened). CLOSED by adding
|
|
670
|
+
the content fsync (tmp → fsync → rename) — strictly more durable, no behavior change.
|
|
671
|
+
|
|
672
|
+
Verdict: **Concur with the review.**
|
|
673
|
+
|
|
674
|
+
### Slice 18 Phase-5 verdict — CONCUR
|
|
675
|
+
An independent reviewer read the builder + the floor + ownership + the proc-table, and found no
|
|
676
|
+
wrong-permit after an adversarial walk:
|
|
677
|
+
- **A polarity:** correct — `deriveOwnerAppRunning` returns `false` (kill-eligible) ONLY when the
|
|
678
|
+
parent-pid is absent from the tree OR its occupant started strictly after the child (reused pid);
|
|
679
|
+
every uncertain/owner-alive branch returns `true` → floor step-5 vetoes. The floor comment now
|
|
680
|
+
matches the logic exactly.
|
|
681
|
+
- **B ordering:** sound — a real parent's start is necessarily ≤ its child's, so `parentMs > childMs
|
|
682
|
+
→ dead` is valid; both lstarts parse through the same Date.parse in the same TZ (offset cancels);
|
|
683
|
+
same-second ties → veto (safe); unparseable → veto.
|
|
684
|
+
- **Tree-completeness (where a wrong-permit could hide):** verified — buildProcTree uses the FULL
|
|
685
|
+
ps output with NO cpu filter and macOS ps lists all same-uid processes, so a live parent is
|
|
686
|
+
always present → branch-2 "absent" means genuinely dead.
|
|
687
|
+
- **C/D/E:** field derivations veto-leaning; `--parentPid`-strip hash collisions only shift breaker
|
|
688
|
+
counts (never the envelope); same-uid + allowlist + sustained + orphaned conjunction bounds an
|
|
689
|
+
argv-attacker to killing a zombie-shaped process they themselves crafted.
|
|
690
|
+
|
|
691
|
+
Verdict: **Concur with the review.**
|
|
692
|
+
|
|
693
|
+
### Slice 20 Phase-5 verdict — CONCUR
|
|
694
|
+
An independent reviewer read the factory + funnel + orchestrator + arm-marker + floor + the two
|
|
695
|
+
fail-safe dependency modules, and found no safety defect yielding a wrong-KILL or a skipped abort:
|
|
696
|
+
- **A async:** awaiting changes no logic — both funnel re-checks survive; every awaited path can
|
|
697
|
+
only ABORT, never introduce a signal; the residual check-then-signal pid-reuse gap is inherent
|
|
698
|
+
POSIX (identical to the sync version), bounded by the startTime-verified stillAlive.
|
|
699
|
+
- **B §4.5 CPU re-confirm:** `stillHog = cores!==null && finite && cores>=threshold`; null/NaN/below
|
|
700
|
+
→ false → floor `not-sustained-hog` veto. No non-hog reaches true through the glue. Strictly
|
|
701
|
+
veto-STRENGTHENING.
|
|
702
|
+
- **C launchctl-empty:** contained — killing a real launchd job ADDITIONALLY requires same-non-root-
|
|
703
|
+
uid + orphaned + sustained + the editor-exthost allowlist match (an unreachable conjunction for a
|
|
704
|
+
managed daemon; the allowlist is the true containment).
|
|
705
|
+
- **D:** pid-reuse → startTime mismatch → null → abort; stale lastOwned → isInstarProcess:false can't
|
|
706
|
+
open a kill (an instar process won't match the exthost regex — ownership isn't the kill-protection).
|
|
707
|
+
- **E arm-scope:** both sides derive from classRuleSources; a matcher change → new hash → re-arm
|
|
708
|
+
required; snapshot keyed by classId (no cross-class arming); any divergence fails toward no-kill.
|
|
709
|
+
- **F:** deliverNotices / classify only raise attention / call the model.
|
|
710
|
+
|
|
711
|
+
Verdict: **Concur with the review.**
|
|
712
|
+
|
|
713
|
+
### Slice 22 Phase-5 verdict — CONCUR
|
|
714
|
+
An independent reviewer traced the load-bearing property (a live kill needs enabled && !dryRun &&
|
|
715
|
+
isMarkerValid && classIsArmed; the marker is written ONLY by armStore, whose sole caller is the
|
|
716
|
+
PIN-gated arm route — grep-confirmed) and found no safety defect:
|
|
717
|
+
- **A PIN gate:** checkMandatePin is the FIRST statement; every failure path (no dashboardPin→503,
|
|
718
|
+
rate-limit→429, missing/non-string/wrong pin→403 timing-safe) sends a response + returns false
|
|
719
|
+
BEFORE any write. Bearer-only / `{}` / PIN-in-wrong-field all yield pin===undefined → 403; even
|
|
720
|
+
absent body-parsing, `req.body ?? {}` fails closed to 403.
|
|
721
|
+
- **B snapshot:** arm's classContentHash(classRuleSources(id)) is the IDENTICAL call the funnel's
|
|
722
|
+
currentClassContentHash makes, same classId key — byte-identical, every allowlist class included.
|
|
723
|
+
- **C disarm:** no PIN (safe); lastDisarmEpoch = max(cur, markerEpoch) ≥ armEpoch invalidates; atomic
|
|
724
|
+
+ fsync write; fs error → 500, never a 200-without-write.
|
|
725
|
+
- **D leak:** returns only epoch/timestamps/class-ids — no token/PIN/secret (armedBy not even surfaced).
|
|
726
|
+
- **E monotonicity:** max(prior, lastDisarm)+1 mints strictly-higher, finite-guarded — stale markers
|
|
727
|
+
can't re-authorize.
|
|
728
|
+
|
|
729
|
+
Verdict: **Concur with the review.**
|
|
730
|
+
|
|
731
|
+
### Slice 23 Phase-5 verdict — CONCUR
|
|
732
|
+
An independent reviewer traced the false-high direction adversarially and found no exploitable
|
|
733
|
+
false-high on an idle process:
|
|
734
|
+
- **A (false-high/window):** `at = nowMs()` is captured BEFORE each exec, so Δwall = exec-latency +
|
|
735
|
+
windowMs (always ≥ the real window) → the denominator is if anything INFLATED → cores DEFLATED
|
|
736
|
+
(safe). prev←a, curr←b order correct. An idle process accrues ~0 cputime between reads regardless
|
|
737
|
+
of timing skew → cores ~0; reaching ≥0.5 cores needs ≥15 real CPU-seconds — a genuinely idle
|
|
738
|
+
process cannot. The small-Δwall inflation guard never trips because the real sleep dominates.
|
|
739
|
+
- **B (pid-reuse):** startTime mismatch → null; negative Δcpu → UNKNOWN. Residual (non-blocking):
|
|
740
|
+
a reused pid in the SAME 1-sec lstart that is ITSELF heavily busy could read high — but that is a
|
|
741
|
+
real hog on the wrong-but-busy pid, the accepted lstart-granularity limit.
|
|
742
|
+
- **C:** pid-gone / undefined / NaN cputime / throw all → null; no parse path yields a spurious high.
|
|
743
|
+
- **D:** contract order correct; a swap → negative Δwall → UNKNOWN → safe.
|
|
744
|
+
|
|
745
|
+
Verdict: **Concur with the review** — fails safe in every direction.
|
|
746
|
+
|
|
747
|
+
### Slice 24 Phase-5 verdict — CONCUR (with one robustness nit folded)
|
|
748
|
+
An independent reviewer traced all five areas — the dark-on-fleet / watch-only-on-dev / PIN-to-kill
|
|
749
|
+
invariant holds:
|
|
750
|
+
- **A interval gating SAFE:** `_externalHogEnabled = resolveDevAgentGate(...) = explicitEnabled ??
|
|
751
|
+
!!config.developmentAgent`. Fleet + enabled-omitted → false → NO setInterval, NO tick, NO kill.
|
|
752
|
+
The interval runs only on developmentAgent:true or explicit enabled:true (both deliberate);
|
|
753
|
+
enabled:false force-darks even a dev agent. guardRegistry.register installs a LAZY getter
|
|
754
|
+
(reads arm/heartbeat only, never tick()).
|
|
755
|
+
- **B dryRun/PIN AIRTIGHT:** default dryRun omitted → true (watch-only). canKillLive needs enabled
|
|
756
|
+
&& !dryRun && isMarkerValid && classIsArmed; construction NEVER writes an arm marker → null →
|
|
757
|
+
invalid. So even enabled:true+dryRun:false yields would-kill until a PIN-route epoch-fenced
|
|
758
|
+
marker exists. NO real signal reachable from construction alone.
|
|
759
|
+
- **C SAFE:** setInterval sits after successful construction inside the try; any throw → undefined →
|
|
760
|
+
routes 503, no timer. Timer unref'd.
|
|
761
|
+
- **D (robustness nit, FOLDED):** ehNum rejected NaN but passed negative/zero/huge finite — a garbage
|
|
762
|
+
scanIntervalMs:0 could hot-loop the DEV tick (no kill-safety impact — the gate is unaffected).
|
|
763
|
+
CLOSED: the interval-critical knobs are now positive-clamped (scanIntervalMs ≥ 1000, cpuCoreThreshold
|
|
764
|
+
≥ 0.1, sampleWindowMs ≥ 1000, sustainedSampleCount ≥ 1).
|
|
765
|
+
- **E SAFE:** externalHogSentinel threads options → ctx (`?? null`); routes get null when dark, no clobber.
|
|
766
|
+
|
|
767
|
+
Verdict: **Concur with the review.**
|
|
768
|
+
|
|
769
|
+
**Slice 26 (cont.) — the full per-component ratchet set.** CI's sharded run surfaced that a new
|
|
770
|
+
LLM component must carry a decision in EVERY per-COMPONENT_CATEGORY registry (not just bench-coverage).
|
|
771
|
+
Added `ExternalHogClassifier` to all of them, each an honest classification: `LLM_UNTRUSTED_INPUT: true`
|
|
772
|
+
(judges the attacker-controllable name/argv), `LLM_JUDGES_CLAIMS: false` (judges a process disposition,
|
|
773
|
+
not a completion/health claim), `LLM_PARSER_CONTRACT: { pending: 'contract-wave-2' }` + its pinned
|
|
774
|
+
baseline (its output is machine-parsed into a closed kill/leave/alert verdict), `LLM_ROUTING_NATURE:
|
|
775
|
+
{ nature: 'A', chain: 'SORT' }` (a background bounded verdict), plus the earlier bench-coverage
|
|
776
|
+
(`zombie-classify`) + the routing-registry row + componentCategories (sentinel) + the CapabilityIndex
|
|
777
|
+
prefix. All 7 ratchet suites green (198 tests). Registry decisions, not kill-logic.
|
|
778
|
+
|
|
779
|
+
**Slice 27 — rebase onto main + the four merge-point ratchet declarations.** CI's merge-commit run
|
|
780
|
+
(vs a main 28 commits ahead) surfaced four declaration ratchets the branch predated; all four are
|
|
781
|
+
registry/annotation decisions, ZERO kill-logic changes:
|
|
782
|
+
- **write-domain (§3.5):** `POST /external-hog/arm` + `POST /external-hog/disarm` classified
|
|
783
|
+
MACHINE-LOCAL in `buildWriteDomainRegistry` with a two-axis convergence story whose logical story
|
|
784
|
+
IS the file-level story: the arm marker (`state/external-hog-arm.json`) is per-machine PIN
|
|
785
|
+
consent, and cross-machine convergence would BE the vulnerability (a synced marker silently arms
|
|
786
|
+
a peer's sentinel the operator never consented to — the silent-re-arm class the armEpoch design
|
|
787
|
+
prevents). File-level arm shipped as a `FileClassifier` sync exclusion for that exact path.
|
|
788
|
+
- **feature-delivery-completeness:** the 'External-Hog Zombie Auto-Kill Sentinel' CLAUDE.md section
|
|
789
|
+
tracked in `legacyMigratorSections` — dark dev-gated monitoring guard, template+migrator parity
|
|
790
|
+
via the shared `EXTERNAL_HOG_CLAUDEMD_SECTION` constant, NOT framework-shadowed (same class as
|
|
791
|
+
'Machine-Coherence Guard' / 'Write Admission').
|
|
792
|
+
- **lint-dev-agent-dark-gate golden map:** the `monitoring.externalHogSentinel` ConfigDefaults block
|
|
793
|
+
(28 lines, OMITS the `enabled` literal — rides resolveDevAgentGate, the #1001 anti-pattern
|
|
794
|
+
avoided) adds NO attributed path; every `enabled: false` line below it shifts +28. Hand-verified
|
|
795
|
+
per entry (path set unchanged, 25 entries).
|
|
796
|
+
- **no-silent-fallbacks:** the PR's 8 net-new flagged catches resolved honestly: the server-boot
|
|
797
|
+
construction-failure catch now REPORTS via DegradationReporter (a guard disabled for the boot is
|
|
798
|
+
a real degradation); the other 7 are reviewed fail-safe catches tagged `@silent-fallback-ok` with
|
|
799
|
+
their safety direction inline (signal-false is the primitive's contract; tick errors are logged;
|
|
800
|
+
verdict-parse falls to the enum shape then null=no-kill; launchctl-empty is the round-8 reviewed
|
|
801
|
+
floor-bounded decision; argv-null skips the candidate; CPU-probe-null refuses the reading; tmux-empty
|
|
802
|
+
leaves the orphan invariant blocking). Count back at main's own level (490 ≤ 491 baseline).
|
|
803
|
+
Also folded at the rebase merge-point: `templates.ts` import-line conflict (main's
|
|
804
|
+
DOORWAY_REGISTRY_CLAUDEMD_SECTION + this branch's EXTERNAL_HOG_CLAUDEMD_SECTION — both kept).
|
|
805
|
+
Rollback: revert the commit; every change is a declaration/annotation, no runtime behavior moves
|
|
806
|
+
except the added DegradationReporter report on a construction-failure path that previously only
|
|
807
|
+
console.error'd.
|
|
808
|
+
|
|
809
|
+
**Slice 27 (cont.) — Self-Action Convergence closure (guard).** Main's new Capacity-Safety gate
|
|
810
|
+
("No Unbounded Self-Action") landed while this branch was in flight; the sentinel IS the class's
|
|
811
|
+
subject (a killer on a 60s loop), so it gets the real closure, not a carve-out: registered
|
|
812
|
+
`external-hog-kill-breaker` in `SELF_ACTION_CONTROLLERS` — and unlike the seeded entries, the
|
|
813
|
+
model drives the REAL pure brake (`isBreakerTripped`/`recordKill` from ExternalHogKillLedger), so
|
|
814
|
+
the ratchet proves the shipped code converges, not a re-model. Pinned worst case: the same
|
|
815
|
+
respawn-surviving signature sustained-hot on every scan forever → kills settle at K=3 within the
|
|
816
|
+
rolling window (2N horizon = exactly one window, settle-is-real exact), then the brake holds; the
|
|
817
|
+
real steady state is the rate bound ≤K/window/signature with one deduped degradation, ledger
|
|
818
|
+
pruned to retention. `@self-action-controller` marker added to ExternalHogSentinel.ts. Convergence
|
|
819
|
+
ratchet green (30 tests).
|
|
820
|
+
|
|
821
|
+
## Class-Closure Declaration (display-only mirror)
|
|
822
|
+
|
|
823
|
+
`unbounded-self-action` → closure **guard**. The sentinel is a self-triggered kill controller (a
|
|
824
|
+
60s scan loop that can signal processes), i.e. the class's exact subject. Guard: registered
|
|
825
|
+
`external-hog-kill-breaker` in `SELF_ACTION_CONTROLLERS` (src/testing/selfActionRegistry.ts),
|
|
826
|
+
covered by the convergence ratchet `tests/unit/self-action-convergence.test.ts` (enforcement:
|
|
827
|
+
ratchet). The model drives the REAL pure brake functions (`isBreakerTripped`/`recordKill` from
|
|
828
|
+
src/monitoring/ExternalHogKillLedger.ts — K=3 per rolling 1h window per signature, class-level
|
|
829
|
+
fallback for volatile keys) under the pinned always-respawning fixture and settles at 3 kills,
|
|
830
|
+
horizon-independent within the window; the real steady state is rate-bounded at ≤K/window/signature
|
|
831
|
+
with one deduped degradation, ledger pruned to retention. Marker `@self-action-controller:
|
|
832
|
+
external-hog-kill-breaker` sits in src/monitoring/ExternalHogSentinel.ts for the forcing lint.
|