@neurodock/cli 0.6.1 → 0.7.0
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/CHANGELOG.md +80 -0
- package/README.md +21 -21
- package/dist/assets/hooks/neurodock_daemon.py +87 -44
- package/dist/assets/hooks/proactive_guardrail.py +121 -37
- package/dist/assets/hooks/test_daemon_escape.py +111 -0
- package/dist/commands/guardrail.d.ts +18 -0
- package/dist/commands/guardrail.d.ts.map +1 -0
- package/dist/commands/guardrail.js +223 -0
- package/dist/commands/guardrail.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +5 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +26 -3
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/plugin.d.ts.map +1 -1
- package/dist/commands/plugin.js +6 -2
- package/dist/commands/plugin.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -1
- package/dist/index.js.map +1 -1
- package/dist/util/atomic-write.d.ts +11 -0
- package/dist/util/atomic-write.d.ts.map +1 -0
- package/dist/util/atomic-write.js +40 -0
- package/dist/util/atomic-write.js.map +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
# @neurodock/cli changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Fixed — proactive-guardrail wiring after 2026-05-26 silent-failure incident
|
|
6
|
+
|
|
7
|
+
The user spent 6 hours coding without a single break warning from the
|
|
8
|
+
substrate. Diagnosis: the Phase 1 hook was writing a degraded session
|
|
9
|
+
shape (`{tool_count: N}` with no `started_at`), and the Phase 3 daemon
|
|
10
|
+
had never been started on the machine — only registered for autostart,
|
|
11
|
+
which would have fired at next login (i.e. never, in a long session).
|
|
12
|
+
Three plumbing fixes, no new heuristics or severity tiers (clinical
|
|
13
|
+
ETHICS contract honoured — `mcp-guardrail` unchanged):
|
|
14
|
+
|
|
15
|
+
- **Phase 1 hook defensive bootstrap**: on `PreToolUse`, if
|
|
16
|
+
`started_at` is missing from session state, set it to `now()`
|
|
17
|
+
rather than silently degrade. Adds `last_active_at` on each
|
|
18
|
+
PreToolUse for the same reason. The existing elapsed-time
|
|
19
|
+
heuristic can finally compute against a real anchor.
|
|
20
|
+
- **`neurodock install-hooks --install-daemon` now actually starts
|
|
21
|
+
the daemon** after registering autostart, via a detached + unref'd
|
|
22
|
+
spawn. Previously it only set up the HKCU Run / LaunchAgent /
|
|
23
|
+
systemd-user entry, which means the user had to log out + back in
|
|
24
|
+
to see the first daemon tick.
|
|
25
|
+
- **`neurodock guardrail status`**: new read-only diagnostic
|
|
26
|
+
subcommand that prints which pieces of the wiring are present
|
|
27
|
+
(hook registered? session shape healthy? daemon script on disk?
|
|
28
|
+
autostart registered? daemon alive in last 15 min?) so the user
|
|
29
|
+
can see at a glance which piece is missing before another long
|
|
30
|
+
session goes silent.
|
|
31
|
+
|
|
32
|
+
No heuristic thresholds touched. No clinical sign-off required.
|
|
33
|
+
|
|
34
|
+
### Security — atomic writes for CLI scaffolding (TOCTOU defence)
|
|
35
|
+
|
|
36
|
+
`neurodock init`, `neurodock install-hooks`, and `neurodock plugin enable` previously
|
|
37
|
+
used an `existsSync` check followed by a separate `writeFileSync`, leaving a
|
|
38
|
+
TOCTOU (time-of-check / time-of-use) window in which a local attacker could swap the
|
|
39
|
+
target path for a symlink between the two calls. All three sites now use atomic
|
|
40
|
+
write primitives: new-file creation uses `O_CREAT | O_EXCL` (one kernel operation
|
|
41
|
+
that makes the check-and-create atomic), while update-in-place sites write to a
|
|
42
|
+
unique `.pid.ts.tmp` sibling and rename it into place — rename is atomic on POSIX
|
|
43
|
+
and best-effort on Windows. A shared helper `src/util/atomic-write.ts` encapsulates
|
|
44
|
+
both patterns.
|
|
45
|
+
|
|
46
|
+
### Fixed — Python hooks no longer swallow exceptions silently
|
|
47
|
+
|
|
48
|
+
Seven `except …: pass` blocks in `proactive_guardrail.py` and `neurodock_daemon.py`
|
|
49
|
+
were changed to `except Exception as exc:` with structured logging. The affected
|
|
50
|
+
sites are session-end timestamp parsing, session-file saves, prompt-file saves, log
|
|
51
|
+
writes, and the daemon dedup-timestamp parse. `KeyboardInterrupt` and `SystemExit`
|
|
52
|
+
continue to propagate because `except Exception` excludes them by design. The log
|
|
53
|
+
write itself falls back to a minimal `sys.stderr` line to avoid infinite recursion.
|
|
54
|
+
|
|
55
|
+
### Fixed — notification-text escape hardened (M5)
|
|
56
|
+
|
|
57
|
+
`_escape()` in `neurodock_daemon.py` was split into two context-aware functions,
|
|
58
|
+
`_escape_ps()` (PowerShell) and `_escape_as()` (AppleScript). Both strip the full
|
|
59
|
+
set of shell-metacharacters that the original single-function omitted: backtick,
|
|
60
|
+
`$(){}`, `;&|<>`, and backslash for PowerShell; `\\&|;\`` for AppleScript.
|
|
61
|
+
Unit tests in `packages/cli/src/assets/hooks/test_daemon_escape.py` assert that
|
|
62
|
+
injection payloads containing all dangerous characters produce literal-text output.
|
|
63
|
+
|
|
64
|
+
## 0.6.2
|
|
65
|
+
|
|
66
|
+
### Fixed — `neurodock --version` was hardcoded and stale (read package.json instead)
|
|
67
|
+
|
|
68
|
+
`src/index.ts` held `export const CLI_VERSION = "0.5.0"`. The string
|
|
69
|
+
went stale at every release: 0.6.0 reported `0.5.0`, 0.6.1 also
|
|
70
|
+
reported `0.5.0`. Fresh users running `neurodock --version` saw a
|
|
71
|
+
number that didn't match what they'd just installed and reasonably
|
|
72
|
+
concluded the install was broken.
|
|
73
|
+
|
|
74
|
+
0.6.2 reads the version from the package's own `package.json` at
|
|
75
|
+
module load. Works from both `dist/index.js` (the published path)
|
|
76
|
+
and `src/index.ts` when run via tsx during dev. Impossible to drift
|
|
77
|
+
again.
|
|
78
|
+
|
|
79
|
+
No other behaviour change vs 0.6.1. If you're already on 0.6.1, this
|
|
80
|
+
is purely cosmetic and you can skip it; new installs should jump
|
|
81
|
+
straight to 0.6.2.
|
|
82
|
+
|
|
3
83
|
## 0.6.1
|
|
4
84
|
|
|
5
85
|
### Fixed — `npm install` was broken for every fresh user (workspace: protocol leaked)
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The `neurodock` installer and diagnostic CLI for [NeuroDock](https://neurodock.org/) — a local-first cognitive substrate for neurodivergent professionals.
|
|
4
4
|
|
|
5
|
-
Status: **v0.6.
|
|
5
|
+
Status: **v0.6.2**.
|
|
6
6
|
|
|
7
7
|
## Quickstart
|
|
8
8
|
|
|
@@ -32,26 +32,26 @@ install, `install-all` runs the `pip install` step automatically.
|
|
|
32
32
|
|
|
33
33
|
## Commands
|
|
34
34
|
|
|
35
|
-
| Command | What it does
|
|
36
|
-
| ---------------------------- |
|
|
37
|
-
| `neurodock install-all` | One-command first-time install: pip-install the six servers, wire every detected client, copy the starter profile.
|
|
38
|
-
| `neurodock init` | Install MCP servers into Claude Desktop / Claude Code / Cursor (the wiring half of `install-all`).
|
|
39
|
-
| `neurodock doctor` | Diagnose your install — profile validity, client wiring, tool availability.
|
|
40
|
-
| `neurodock validate` | Schema-validate a profile file (`~/.neurodock/profile.yaml` by default).
|
|
41
|
-
| `neurodock update` | Upgrade NeuroDock to the latest version — re-installs the six MCP servers via pip/uv and re-wires client configs.
|
|
42
|
-
| `neurodock sync` | Re-shape stale NeuroDock MCP entries in existing client configs. No package upgrade. Non-NeuroDock entries are preserved.
|
|
43
|
-
| `neurodock uninstall` | Reverse `init` — remove NeuroDock MCP entries from each client config. Optionally purge `~/.neurodock/`.
|
|
44
|
-
| `neurodock examples` | Print a copy-pasteable prompt cheat-sheet that exercises every wired NeuroDock MCP tool.
|
|
45
|
-
| `neurodock host install` | Register the optional Chrome Native Messaging host so the browser extension can read `~/.neurodock/profile.yaml` directly.
|
|
46
|
-
| `neurodock host uninstall` | Remove all NeuroDock native-host manifests and registry pointers.
|
|
47
|
-
| `neurodock profile show` | Print the resolved profile with loader defaults applied.
|
|
48
|
-
| `neurodock profile validate` | Validate `~/.neurodock/profile.yaml` against the v0.1 schema.
|
|
49
|
-
| `neurodock plugin add` | Install a plugin from a local directory into `~/.neurodock/plugins/`.
|
|
50
|
-
| `neurodock plugin remove` | Uninstall a plugin (alias: `plugin uninstall`).
|
|
51
|
-
| `neurodock plugin list` | List installed plugins and their enabled state. `--json` for scripting.
|
|
52
|
-
| `neurodock plugin enable` | Activate an installed plugin (writes a `.enabled` marker file).
|
|
53
|
-
| `neurodock plugin disable` | Deactivate an installed plugin without deleting its files.
|
|
54
|
-
| `neurodock plugin validate` | Schema-validate a plugin manifest without installing.
|
|
35
|
+
| Command | What it does |
|
|
36
|
+
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
37
|
+
| `neurodock install-all` | One-command first-time install: pip-install the six servers, wire every detected client, copy the starter profile. |
|
|
38
|
+
| `neurodock init` | Install MCP servers into Claude Desktop / Claude Code / Cursor (the wiring half of `install-all`). |
|
|
39
|
+
| `neurodock doctor` | Diagnose your install — profile validity, client wiring, tool availability. |
|
|
40
|
+
| `neurodock validate` | Schema-validate a profile file (`~/.neurodock/profile.yaml` by default). |
|
|
41
|
+
| `neurodock update` | Upgrade NeuroDock to the latest version — re-installs the six MCP servers via pip/uv and re-wires client configs. |
|
|
42
|
+
| `neurodock sync` | Re-shape stale NeuroDock MCP entries in existing client configs. No package upgrade. Non-NeuroDock entries are preserved. |
|
|
43
|
+
| `neurodock uninstall` | Reverse `init` — remove NeuroDock MCP entries from each client config. Optionally purge `~/.neurodock/`. |
|
|
44
|
+
| `neurodock examples` | Print a copy-pasteable prompt cheat-sheet that exercises every wired NeuroDock MCP tool. |
|
|
45
|
+
| `neurodock host install` | Register the optional Chrome Native Messaging host so the browser extension can read `~/.neurodock/profile.yaml` directly. |
|
|
46
|
+
| `neurodock host uninstall` | Remove all NeuroDock native-host manifests and registry pointers. |
|
|
47
|
+
| `neurodock profile show` | Print the resolved profile with loader defaults applied. |
|
|
48
|
+
| `neurodock profile validate` | Validate `~/.neurodock/profile.yaml` against the v0.1 schema. |
|
|
49
|
+
| `neurodock plugin add` | Install a plugin from a local directory into `~/.neurodock/plugins/`. |
|
|
50
|
+
| `neurodock plugin remove` | Uninstall a plugin (alias: `plugin uninstall`). |
|
|
51
|
+
| `neurodock plugin list` | List installed plugins and their enabled state. `--json` for scripting. |
|
|
52
|
+
| `neurodock plugin enable` | Activate an installed plugin (writes a `.enabled` marker file). |
|
|
53
|
+
| `neurodock plugin disable` | Deactivate an installed plugin without deleting its files. |
|
|
54
|
+
| `neurodock plugin validate` | Schema-validate a plugin manifest without installing. |
|
|
55
55
|
| `neurodock install-hooks` | Wire the proactive-guardrail hook into Claude Code, optionally autostart the Phase 3 daemon. See `--self-test`, `--install-daemon`, `--uninstall`, `--dry-run`. |
|
|
56
56
|
|
|
57
57
|
### `neurodock install-all`
|
|
@@ -46,7 +46,7 @@ import platform
|
|
|
46
46
|
import subprocess
|
|
47
47
|
import sys
|
|
48
48
|
import time
|
|
49
|
-
from datetime import datetime, timedelta
|
|
49
|
+
from datetime import UTC, datetime, timedelta
|
|
50
50
|
from pathlib import Path
|
|
51
51
|
from typing import Any
|
|
52
52
|
|
|
@@ -67,8 +67,8 @@ DEDUP_SECONDS = 30 * 60
|
|
|
67
67
|
# Heuristic thresholds — mirror the Phase 1 hook so the user gets a
|
|
68
68
|
# consistent experience whether they're in Claude Code or not.
|
|
69
69
|
HYPERFOCUS_BREAK_MINUTES = 90
|
|
70
|
-
DEEP_NIGHT_HOURS = range(0, 6)
|
|
71
|
-
LATE_NIGHT_HOURS = range(22, 24)
|
|
70
|
+
DEEP_NIGHT_HOURS = range(0, 6) # 00:00..05:59 local
|
|
71
|
+
LATE_NIGHT_HOURS = range(22, 24) # 22:00..23:59 local
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
def main() -> int:
|
|
@@ -117,7 +117,7 @@ def _run_forever() -> int:
|
|
|
117
117
|
except KeyboardInterrupt:
|
|
118
118
|
_log("daemon-stop", {"reason": "sigint"})
|
|
119
119
|
return 0
|
|
120
|
-
except Exception as exc: #
|
|
120
|
+
except Exception as exc: # broad-except: must not crash autostart
|
|
121
121
|
_log("daemon-error", {"error": str(exc)})
|
|
122
122
|
return 1
|
|
123
123
|
|
|
@@ -133,16 +133,13 @@ def _run_one_tick() -> int:
|
|
|
133
133
|
last = state.get("last_surfaced", {})
|
|
134
134
|
last_kind = last.get("kind")
|
|
135
135
|
last_at = last.get("at")
|
|
136
|
-
if (
|
|
137
|
-
last_kind == signal["kind"]
|
|
138
|
-
and isinstance(last_at, str)
|
|
139
|
-
):
|
|
136
|
+
if last_kind == signal["kind"] and isinstance(last_at, str):
|
|
140
137
|
try:
|
|
141
138
|
elapsed = (now - datetime.fromisoformat(last_at)).total_seconds()
|
|
142
139
|
if elapsed < DEDUP_SECONDS:
|
|
143
140
|
return 0
|
|
144
|
-
except
|
|
145
|
-
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
_log("daemon-dedup-parse-error", {"error": str(exc)})
|
|
146
143
|
_surface(signal)
|
|
147
144
|
state["last_surfaced"] = {
|
|
148
145
|
"kind": signal["kind"],
|
|
@@ -217,7 +214,7 @@ def _surface(signal: dict[str, Any]) -> None:
|
|
|
217
214
|
_notify_macos(title, message)
|
|
218
215
|
else:
|
|
219
216
|
_notify_linux(title, message)
|
|
220
|
-
except Exception as exc:
|
|
217
|
+
except Exception as exc:
|
|
221
218
|
_log("notify-error", {"error": str(exc)})
|
|
222
219
|
|
|
223
220
|
|
|
@@ -225,14 +222,18 @@ def _notify_windows(title: str, message: str) -> None:
|
|
|
225
222
|
# Use PowerShell BurntToast-free approach: New-BurntToastNotification
|
|
226
223
|
# isn't always present. Use the built-in toast API via XML through
|
|
227
224
|
# the Windows Runtime instead. Falls back to msg.exe on failure.
|
|
225
|
+
#
|
|
226
|
+
# Security: title and message come from hardcoded daemon logic today,
|
|
227
|
+
# but _escape_ps() defensively strips shell-metacharacters so future
|
|
228
|
+
# callers cannot inject PowerShell via the notification body.
|
|
228
229
|
ps_script = (
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
f'$template.GetElementsByTagName("text").Item(0).AppendChild($template.CreateTextNode("{
|
|
233
|
-
f'$template.GetElementsByTagName("text").Item(1).AppendChild($template.CreateTextNode("{
|
|
230
|
+
"[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime] | Out-Null;"
|
|
231
|
+
"$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent("
|
|
232
|
+
"[Windows.UI.Notifications.ToastTemplateType]::ToastText02);"
|
|
233
|
+
f'$template.GetElementsByTagName("text").Item(0).AppendChild($template.CreateTextNode("{_escape_ps(title)}")) | Out-Null;'
|
|
234
|
+
f'$template.GetElementsByTagName("text").Item(1).AppendChild($template.CreateTextNode("{_escape_ps(message)}")) | Out-Null;'
|
|
234
235
|
'$notify = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("NeuroDock");'
|
|
235
|
-
|
|
236
|
+
"$notify.Show([Windows.UI.Notifications.ToastNotification]::new($template));"
|
|
236
237
|
)
|
|
237
238
|
subprocess.run(
|
|
238
239
|
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
|
|
@@ -243,9 +244,10 @@ def _notify_windows(title: str, message: str) -> None:
|
|
|
243
244
|
|
|
244
245
|
|
|
245
246
|
def _notify_macos(title: str, message: str) -> None:
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
# Security: osascript -e interpolates title/message into the AppleScript
|
|
248
|
+
# source. _escape_as() strips characters that would break out of the
|
|
249
|
+
# quoted string context inside the AppleScript literal.
|
|
250
|
+
script = f'display notification "{_escape_as(message)}" with title "{_escape_as(title)}"'
|
|
249
251
|
subprocess.run(
|
|
250
252
|
["osascript", "-e", script],
|
|
251
253
|
check=False,
|
|
@@ -258,6 +260,8 @@ def _notify_linux(title: str, message: str) -> None:
|
|
|
258
260
|
# Most desktop environments have notify-send. We don't fail loud if
|
|
259
261
|
# it's absent — Linux server users without a display server will
|
|
260
262
|
# see the daemon log entries but no toast.
|
|
263
|
+
# Security: title and message are passed as separate argv elements —
|
|
264
|
+
# no shell interpolation, no escaping required.
|
|
261
265
|
subprocess.run(
|
|
262
266
|
["notify-send", "-a", "NeuroDock", title, message],
|
|
263
267
|
check=False,
|
|
@@ -266,8 +270,45 @@ def _notify_linux(title: str, message: str) -> None:
|
|
|
266
270
|
)
|
|
267
271
|
|
|
268
272
|
|
|
269
|
-
|
|
270
|
-
|
|
273
|
+
# Characters to STRIP from PowerShell double-quoted string interpolation.
|
|
274
|
+
# Note: double-quote itself is NOT in this set — it is backslash-escaped
|
|
275
|
+
# by _escape_ps() instead of being stripped, so the text node value is
|
|
276
|
+
# preserved. Backtick is the PS escape character, so it must be stripped.
|
|
277
|
+
_PS_STRIP = str.maketrans(
|
|
278
|
+
"",
|
|
279
|
+
"",
|
|
280
|
+
"`$(){};&|<>\\\n\r",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Characters to STRIP from AppleScript double-quoted string interpolation.
|
|
284
|
+
# AppleScript has no reliable backslash escape for double-quote inside a
|
|
285
|
+
# quoted string (the behaviour is interpreter-version-dependent), so both
|
|
286
|
+
# double-quote and backslash are stripped rather than escaped.
|
|
287
|
+
_AS_STRIP = str.maketrans(
|
|
288
|
+
"",
|
|
289
|
+
"",
|
|
290
|
+
'"\\&|;`\n\r',
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _escape_ps(s: str) -> str:
|
|
295
|
+
"""Strip PS-dangerous chars; backslash-escape remaining double-quotes.
|
|
296
|
+
|
|
297
|
+
The double-quote is backslash-escaped (not stripped) so that the XML
|
|
298
|
+
text node still receives the literal quote character.
|
|
299
|
+
"""
|
|
300
|
+
stripped = s.translate(_PS_STRIP)
|
|
301
|
+
return stripped.replace('"', '\\"')
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _escape_as(s: str) -> str:
|
|
305
|
+
"""Strip AppleScript-dangerous chars including double-quote.
|
|
306
|
+
|
|
307
|
+
AppleScript double-quoted literals cannot reliably escape a quote via
|
|
308
|
+
backslash on all macOS versions, so the double-quote is stripped
|
|
309
|
+
entirely rather than kept.
|
|
310
|
+
"""
|
|
311
|
+
return s.translate(_AS_STRIP)
|
|
271
312
|
|
|
272
313
|
|
|
273
314
|
# ── Autostart wiring ─────────────────────────────────────────────────────
|
|
@@ -279,8 +320,7 @@ def _install_autostart() -> int:
|
|
|
279
320
|
daemon_script = (HOOK_DIR / "neurodock_daemon.py").resolve()
|
|
280
321
|
if not daemon_script.exists():
|
|
281
322
|
sys.stderr.write(
|
|
282
|
-
f"daemon script not found at {daemon_script} — run "
|
|
283
|
-
"`neurodock install-hooks` first.\n"
|
|
323
|
+
f"daemon script not found at {daemon_script} — run `neurodock install-hooks` first.\n"
|
|
284
324
|
)
|
|
285
325
|
return 1
|
|
286
326
|
if system == "Windows":
|
|
@@ -302,25 +342,26 @@ def _uninstall_autostart() -> int:
|
|
|
302
342
|
def _install_windows_autostart(daemon_script: Path) -> int:
|
|
303
343
|
# Register an HKCU Run entry so the daemon launches at user logon.
|
|
304
344
|
# No admin rights required.
|
|
305
|
-
cmd = (
|
|
306
|
-
f'python "{str(daemon_script).replace(chr(92), "/")}" run'
|
|
307
|
-
)
|
|
345
|
+
cmd = f'python "{str(daemon_script).replace(chr(92), "/")}" run'
|
|
308
346
|
try:
|
|
309
347
|
subprocess.run(
|
|
310
348
|
[
|
|
311
|
-
"reg",
|
|
312
|
-
"
|
|
313
|
-
"
|
|
314
|
-
"/
|
|
349
|
+
"reg",
|
|
350
|
+
"add",
|
|
351
|
+
r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
|
|
352
|
+
"/v",
|
|
353
|
+
"NeuroDockGuardrail",
|
|
354
|
+
"/t",
|
|
355
|
+
"REG_SZ",
|
|
356
|
+
"/d",
|
|
357
|
+
cmd,
|
|
315
358
|
"/f",
|
|
316
359
|
],
|
|
317
360
|
check=True,
|
|
318
361
|
capture_output=True,
|
|
319
362
|
timeout=10,
|
|
320
363
|
)
|
|
321
|
-
sys.stdout.write(
|
|
322
|
-
"Registered HKCU Run entry. Daemon will launch at next logon.\n"
|
|
323
|
-
)
|
|
364
|
+
sys.stdout.write("Registered HKCU Run entry. Daemon will launch at next logon.\n")
|
|
324
365
|
return 0
|
|
325
366
|
except subprocess.CalledProcessError as exc:
|
|
326
367
|
sys.stderr.write(f"reg add failed: {exc.stderr.decode(errors='ignore')}\n")
|
|
@@ -331,8 +372,11 @@ def _uninstall_windows_autostart() -> int:
|
|
|
331
372
|
try:
|
|
332
373
|
subprocess.run(
|
|
333
374
|
[
|
|
334
|
-
"reg",
|
|
335
|
-
"
|
|
375
|
+
"reg",
|
|
376
|
+
"delete",
|
|
377
|
+
r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
|
|
378
|
+
"/v",
|
|
379
|
+
"NeuroDockGuardrail",
|
|
336
380
|
"/f",
|
|
337
381
|
],
|
|
338
382
|
check=True,
|
|
@@ -410,9 +454,7 @@ WantedBy=default.target
|
|
|
410
454
|
|
|
411
455
|
|
|
412
456
|
def _uninstall_linux_systemd_user_unit() -> int:
|
|
413
|
-
unit = (
|
|
414
|
-
Path.home() / ".config" / "systemd" / "user" / "neurodock-guardrail.service"
|
|
415
|
-
)
|
|
457
|
+
unit = Path.home() / ".config" / "systemd" / "user" / "neurodock-guardrail.service"
|
|
416
458
|
subprocess.run(
|
|
417
459
|
["systemctl", "--user", "disable", "--now", "neurodock-guardrail.service"],
|
|
418
460
|
check=False,
|
|
@@ -449,8 +491,8 @@ def _save_daemon_state(state: dict[str, Any]) -> None:
|
|
|
449
491
|
try:
|
|
450
492
|
with DAEMON_STATE_FILE.open("w", encoding="utf-8") as fh:
|
|
451
493
|
json.dump(state, fh)
|
|
452
|
-
except
|
|
453
|
-
|
|
494
|
+
except Exception as exc:
|
|
495
|
+
_log("daemon-state-save-error", {"error": str(exc)})
|
|
454
496
|
|
|
455
497
|
|
|
456
498
|
def _log(event: str, data: dict[str, Any]) -> None:
|
|
@@ -463,12 +505,13 @@ def _log(event: str, data: dict[str, Any]) -> None:
|
|
|
463
505
|
}
|
|
464
506
|
with LOG_FILE.open("a", encoding="utf-8") as fh:
|
|
465
507
|
fh.write(json.dumps(entry) + "\n")
|
|
466
|
-
except
|
|
467
|
-
|
|
508
|
+
except Exception as exc:
|
|
509
|
+
# Cannot recurse into _log here; write minimally to stderr.
|
|
510
|
+
sys.stderr.write(f"[neurodock-daemon] log-write-error: {exc}\n")
|
|
468
511
|
|
|
469
512
|
|
|
470
513
|
def _now() -> datetime:
|
|
471
|
-
return datetime.now(
|
|
514
|
+
return datetime.now(UTC).astimezone()
|
|
472
515
|
|
|
473
516
|
|
|
474
517
|
# ── Self-test ────────────────────────────────────────────────────────────
|
|
@@ -56,8 +56,7 @@ import json
|
|
|
56
56
|
import os
|
|
57
57
|
import re
|
|
58
58
|
import sys
|
|
59
|
-
from
|
|
60
|
-
from datetime import datetime, time, timedelta, timezone
|
|
59
|
+
from datetime import UTC, datetime, timedelta
|
|
61
60
|
from pathlib import Path
|
|
62
61
|
from typing import Any
|
|
63
62
|
|
|
@@ -71,9 +70,9 @@ PROMPTS_FILE = STATE_DIR / "guardrail-prompts.json"
|
|
|
71
70
|
|
|
72
71
|
# Hyperfocus heuristic — mirrors packages/mcp-guardrail/heuristics/hyperfocus.py
|
|
73
72
|
HYPERFOCUS_BREAK_MINUTES_DEFAULT = 90
|
|
74
|
-
HYPERFOCUS_GENTLE_RATIO = 0.60
|
|
75
|
-
HYPERFOCUS_NUDGE_RATIO = 1.00
|
|
76
|
-
HYPERFOCUS_HARD_RATIO = 4.0 / 3.0
|
|
73
|
+
HYPERFOCUS_GENTLE_RATIO = 0.60 # 54 min
|
|
74
|
+
HYPERFOCUS_NUDGE_RATIO = 1.00 # 90 min
|
|
75
|
+
HYPERFOCUS_HARD_RATIO = 4.0 / 3.0 # 120 min
|
|
77
76
|
|
|
78
77
|
# Rumination heuristic — Jaccard similarity over normalised word sets.
|
|
79
78
|
RUMINATION_WINDOW_MINUTES_DEFAULT = 90
|
|
@@ -89,8 +88,8 @@ MAX_PROMPT_HISTORY = 200
|
|
|
89
88
|
|
|
90
89
|
# Deep-night / late-night clock bands trigger an early-warning banner
|
|
91
90
|
# at session-start. End-of-day defaults align with `profile.example.yaml`.
|
|
92
|
-
DEEP_NIGHT_HOURS = range(0, 6)
|
|
93
|
-
LATE_NIGHT_HOURS = range(22, 24)
|
|
91
|
+
DEEP_NIGHT_HOURS = range(0, 6) # 00:00..05:59 local
|
|
92
|
+
LATE_NIGHT_HOURS = range(22, 24) # 22:00..23:59 local
|
|
94
93
|
|
|
95
94
|
|
|
96
95
|
# ── Main dispatch ────────────────────────────────────────────────────────
|
|
@@ -118,7 +117,7 @@ def main() -> int:
|
|
|
118
117
|
_on_stop(payload)
|
|
119
118
|
elif kind == "self-test":
|
|
120
119
|
return _self_test()
|
|
121
|
-
except Exception as exc:
|
|
120
|
+
except Exception as exc:
|
|
122
121
|
_log("error", {"kind": kind, "error": str(exc)})
|
|
123
122
|
return 0
|
|
124
123
|
|
|
@@ -144,7 +143,17 @@ def _on_session_start(_payload: dict[str, Any]) -> None:
|
|
|
144
143
|
|
|
145
144
|
def _on_pre_tool(payload: dict[str, Any]) -> None:
|
|
146
145
|
state = _load_session()
|
|
146
|
+
# Defensive bootstrap: if SessionStart never fired (e.g. hook installed
|
|
147
|
+
# mid-session, or the Claude Code event is suppressed in a given client),
|
|
148
|
+
# the elapsed-time heuristics need an anchor. Set started_at on the first
|
|
149
|
+
# PreToolUse rather than silently degrade. The user's 2026-05-26 silent-
|
|
150
|
+
# failure incident hit exactly this path — state had {tool_count: N} with
|
|
151
|
+
# no started_at, so _evaluate_hyperfocus returned None forever.
|
|
152
|
+
if not isinstance(state.get("started_at"), str):
|
|
153
|
+
state["started_at"] = _now().isoformat()
|
|
154
|
+
_log("session-bootstrap", {"reason": "missing-started_at"})
|
|
147
155
|
state["tool_count"] = int(state.get("tool_count", 0)) + 1
|
|
156
|
+
state["last_active_at"] = _now().isoformat()
|
|
148
157
|
_save_session(state)
|
|
149
158
|
|
|
150
159
|
prompt = _extract_user_prompt(payload)
|
|
@@ -179,8 +188,8 @@ def _on_stop(_payload: dict[str, Any]) -> None:
|
|
|
179
188
|
try:
|
|
180
189
|
elapsed = _now() - datetime.fromisoformat(started)
|
|
181
190
|
duration_min = int(elapsed.total_seconds() // 60)
|
|
182
|
-
except
|
|
183
|
-
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
_log("session-end-parse-error", {"error": str(exc)})
|
|
184
193
|
_save_session({}) # clear
|
|
185
194
|
_log("session-end", {"duration_min": duration_min})
|
|
186
195
|
|
|
@@ -249,10 +258,7 @@ def _evaluate_rumination() -> str | None:
|
|
|
249
258
|
if len(prompts) < RUMINATION_THRESHOLD_DEFAULT:
|
|
250
259
|
return None
|
|
251
260
|
window_start = _now() - timedelta(minutes=RUMINATION_WINDOW_MINUTES_DEFAULT)
|
|
252
|
-
recent = [
|
|
253
|
-
p for p in prompts
|
|
254
|
-
if _parse_iso(p.get("at", "")) >= window_start
|
|
255
|
-
]
|
|
261
|
+
recent = [p for p in prompts if _parse_iso(p.get("at", "")) >= window_start]
|
|
256
262
|
if len(recent) < RUMINATION_THRESHOLD_DEFAULT:
|
|
257
263
|
return None
|
|
258
264
|
# Compare the latest prompt to the others.
|
|
@@ -278,17 +284,30 @@ def _evaluate_sycophancy(response: str) -> str | None:
|
|
|
278
284
|
return None
|
|
279
285
|
opener = response.lstrip()[:200].lower()
|
|
280
286
|
absolutes = [
|
|
281
|
-
"absolutely",
|
|
282
|
-
"
|
|
283
|
-
"
|
|
287
|
+
"absolutely",
|
|
288
|
+
"exactly right",
|
|
289
|
+
"you're right",
|
|
290
|
+
"you are right",
|
|
291
|
+
"great point",
|
|
292
|
+
"excellent point",
|
|
293
|
+
"perfect",
|
|
294
|
+
"spot on",
|
|
295
|
+
"100%",
|
|
296
|
+
"100 percent",
|
|
284
297
|
]
|
|
285
298
|
hits = [phrase for phrase in absolutes if phrase in opener]
|
|
286
299
|
if not hits:
|
|
287
300
|
return None
|
|
288
301
|
# If the response contains a trade-off marker, treat it as balanced.
|
|
289
302
|
tradeoff_markers = [
|
|
290
|
-
"however",
|
|
291
|
-
"
|
|
303
|
+
"however",
|
|
304
|
+
"trade-off",
|
|
305
|
+
"tradeoff",
|
|
306
|
+
"downside",
|
|
307
|
+
"but ",
|
|
308
|
+
"although",
|
|
309
|
+
"the cost is",
|
|
310
|
+
"the risk is",
|
|
292
311
|
]
|
|
293
312
|
if any(marker in response.lower() for marker in tradeoff_markers):
|
|
294
313
|
return None
|
|
@@ -308,16 +327,80 @@ def _jaccard_similarity(a: str, b: str) -> float:
|
|
|
308
327
|
return intersection / union if union > 0 else 0.0
|
|
309
328
|
|
|
310
329
|
|
|
311
|
-
_STOP_WORDS = frozenset(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
330
|
+
_STOP_WORDS = frozenset(
|
|
331
|
+
{
|
|
332
|
+
"the",
|
|
333
|
+
"a",
|
|
334
|
+
"an",
|
|
335
|
+
"and",
|
|
336
|
+
"or",
|
|
337
|
+
"but",
|
|
338
|
+
"is",
|
|
339
|
+
"are",
|
|
340
|
+
"was",
|
|
341
|
+
"were",
|
|
342
|
+
"be",
|
|
343
|
+
"been",
|
|
344
|
+
"being",
|
|
345
|
+
"have",
|
|
346
|
+
"has",
|
|
347
|
+
"had",
|
|
348
|
+
"do",
|
|
349
|
+
"does",
|
|
350
|
+
"did",
|
|
351
|
+
"will",
|
|
352
|
+
"would",
|
|
353
|
+
"could",
|
|
354
|
+
"should",
|
|
355
|
+
"may",
|
|
356
|
+
"might",
|
|
357
|
+
"must",
|
|
358
|
+
"shall",
|
|
359
|
+
"can",
|
|
360
|
+
"of",
|
|
361
|
+
"in",
|
|
362
|
+
"on",
|
|
363
|
+
"at",
|
|
364
|
+
"to",
|
|
365
|
+
"for",
|
|
366
|
+
"with",
|
|
367
|
+
"by",
|
|
368
|
+
"from",
|
|
369
|
+
"as",
|
|
370
|
+
"if",
|
|
371
|
+
"then",
|
|
372
|
+
"else",
|
|
373
|
+
"when",
|
|
374
|
+
"where",
|
|
375
|
+
"why",
|
|
376
|
+
"how",
|
|
377
|
+
"what",
|
|
378
|
+
"which",
|
|
379
|
+
"who",
|
|
380
|
+
"this",
|
|
381
|
+
"that",
|
|
382
|
+
"these",
|
|
383
|
+
"those",
|
|
384
|
+
"i",
|
|
385
|
+
"you",
|
|
386
|
+
"he",
|
|
387
|
+
"she",
|
|
388
|
+
"it",
|
|
389
|
+
"we",
|
|
390
|
+
"they",
|
|
391
|
+
"me",
|
|
392
|
+
"him",
|
|
393
|
+
"her",
|
|
394
|
+
"us",
|
|
395
|
+
"them",
|
|
396
|
+
"my",
|
|
397
|
+
"your",
|
|
398
|
+
"his",
|
|
399
|
+
"its",
|
|
400
|
+
"our",
|
|
401
|
+
"their",
|
|
402
|
+
}
|
|
403
|
+
)
|
|
321
404
|
|
|
322
405
|
|
|
323
406
|
def _normalise_for_similarity(text: str) -> set[str]:
|
|
@@ -342,7 +425,7 @@ def _clock_band(now: datetime) -> str:
|
|
|
342
425
|
|
|
343
426
|
|
|
344
427
|
def _now() -> datetime:
|
|
345
|
-
return datetime.now(
|
|
428
|
+
return datetime.now(UTC).astimezone()
|
|
346
429
|
|
|
347
430
|
|
|
348
431
|
# ── Payload extraction (best-effort against Claude Code shape) ───────────
|
|
@@ -394,8 +477,8 @@ def _save_session(state: dict[str, Any]) -> None:
|
|
|
394
477
|
try:
|
|
395
478
|
with SESSION_FILE.open("w", encoding="utf-8") as fh:
|
|
396
479
|
json.dump(state, fh)
|
|
397
|
-
except
|
|
398
|
-
|
|
480
|
+
except Exception as exc:
|
|
481
|
+
_log("session-save-error", {"error": str(exc)})
|
|
399
482
|
|
|
400
483
|
|
|
401
484
|
def _load_prompts() -> list[dict[str, Any]]:
|
|
@@ -415,15 +498,15 @@ def _record_prompt(text: str) -> None:
|
|
|
415
498
|
try:
|
|
416
499
|
with PROMPTS_FILE.open("w", encoding="utf-8") as fh:
|
|
417
500
|
json.dump(prompts, fh)
|
|
418
|
-
except
|
|
419
|
-
|
|
501
|
+
except Exception as exc:
|
|
502
|
+
_log("prompt-save-error", {"error": str(exc)})
|
|
420
503
|
|
|
421
504
|
|
|
422
505
|
def _parse_iso(value: str) -> datetime:
|
|
423
506
|
try:
|
|
424
507
|
return datetime.fromisoformat(value)
|
|
425
508
|
except ValueError:
|
|
426
|
-
return datetime.min.replace(tzinfo=
|
|
509
|
+
return datetime.min.replace(tzinfo=UTC)
|
|
427
510
|
|
|
428
511
|
|
|
429
512
|
# ── Output ───────────────────────────────────────────────────────────────
|
|
@@ -446,8 +529,9 @@ def _log(event: str, data: dict[str, Any]) -> None:
|
|
|
446
529
|
}
|
|
447
530
|
with LOG_FILE.open("a", encoding="utf-8") as fh:
|
|
448
531
|
fh.write(json.dumps(entry) + "\n")
|
|
449
|
-
except
|
|
450
|
-
|
|
532
|
+
except Exception as exc:
|
|
533
|
+
# Cannot recurse into _log here; write minimally to stderr.
|
|
534
|
+
sys.stderr.write(f"[neurodock-guardrail] log-write-error: {exc}\n")
|
|
451
535
|
|
|
452
536
|
|
|
453
537
|
def _read_stdin_payload() -> dict[str, Any]:
|