nexo-brain 7.1.8 → 7.2.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/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/package.json +2 -2
- package/src/auto_update.py +239 -8
- package/src/autonomy_mandate.py +62 -0
- package/src/checkpoint_policy.py +302 -0
- package/src/cli.py +229 -0
- package/src/core_schedule_controls.py +66 -0
- package/src/doctor/providers/boot.py +190 -0
- package/src/evolution_cycle.py +4 -0
- package/src/guardian_runtime_config.py +98 -0
- package/src/hook_guardrails.py +148 -2
- package/src/hooks/g1_enforcer.py +305 -0
- package/src/hooks/post-compact.sh +34 -0
- package/src/hooks/post_tool_use.py +32 -3
- package/src/hooks/pre-compact.sh +14 -0
- package/src/paths.py +10 -0
- package/src/plugins/adaptive_mode.py +26 -2
- package/src/plugins/protocol.py +24 -0
- package/src/plugins/recover.py +42 -10
- package/src/plugins/update.py +47 -17
- package/src/plugins/workflow.py +65 -0
- package/src/public_contribution.py +51 -5
- package/src/r34_identity_coherence.py +31 -8
- package/src/script_registry.py +14 -6
- package/src/scripts/nexo-watchdog.sh +7 -1
- package/src/scripts/prune_runtime_backups.py +376 -0
- package/src/skills/run-release-final-audit/guide.md +3 -1
- package/src/skills/run-release-final-audit/script.py +2 -0
- package/src/tools_sessions.py +64 -3
- package/templates/core-prompts/hook-protocol-warning-task-close-evidence.md +1 -1
- package/templates/core-prompts/r14-correction-learning-injection.md +1 -1
|
@@ -220,6 +220,193 @@ def check_config_parse() -> DoctorCheck:
|
|
|
220
220
|
)
|
|
221
221
|
|
|
222
222
|
|
|
223
|
+
def check_core_dev_packaged_install() -> DoctorCheck:
|
|
224
|
+
"""Warn when ``~/.nexo/core-dev/`` exists on a packaged (non-dev) install.
|
|
225
|
+
|
|
226
|
+
Contract (see ``docs/f06-layout-contract.md`` §3): ``core-dev/`` is a
|
|
227
|
+
developer opt-in and MUST be absent on production installs. Its presence
|
|
228
|
+
on a packaged install is almost always a leftover from a dev environment
|
|
229
|
+
that was later repackaged, and silently keeps parallel code paths
|
|
230
|
+
discoverable through ``_classify_script_dir``. Doctor surfaces it so the
|
|
231
|
+
operator can confirm and remove.
|
|
232
|
+
"""
|
|
233
|
+
import paths
|
|
234
|
+
core_dev = paths.core_dev_dir()
|
|
235
|
+
if not core_dev.exists():
|
|
236
|
+
return DoctorCheck(
|
|
237
|
+
id="boot.core_dev_absent_on_packaged",
|
|
238
|
+
tier="boot",
|
|
239
|
+
status="healthy",
|
|
240
|
+
severity="info",
|
|
241
|
+
summary="core-dev/ absent (expected on packaged installs)",
|
|
242
|
+
)
|
|
243
|
+
is_packaged = paths.core_dir().is_dir() and not (NEXO_HOME / "src").is_dir()
|
|
244
|
+
if not is_packaged:
|
|
245
|
+
return DoctorCheck(
|
|
246
|
+
id="boot.core_dev_absent_on_packaged",
|
|
247
|
+
tier="boot",
|
|
248
|
+
status="healthy",
|
|
249
|
+
severity="info",
|
|
250
|
+
summary="core-dev/ present on a dev install (contract allows this)",
|
|
251
|
+
)
|
|
252
|
+
try:
|
|
253
|
+
payload = [p.name for p in core_dev.iterdir()][:5]
|
|
254
|
+
except OSError:
|
|
255
|
+
payload = []
|
|
256
|
+
return DoctorCheck(
|
|
257
|
+
id="boot.core_dev_absent_on_packaged",
|
|
258
|
+
tier="boot",
|
|
259
|
+
status="degraded",
|
|
260
|
+
severity="warn",
|
|
261
|
+
summary="core-dev/ present on a packaged install — contract forbids this",
|
|
262
|
+
evidence=[f"Location: {core_dev}"] + [f"Entry: {n}" for n in payload],
|
|
263
|
+
repair_plan=[
|
|
264
|
+
f"Confirm with operator, then: rm -rf {core_dev}",
|
|
265
|
+
],
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def check_dashboard_desktop_contract() -> DoctorCheck:
|
|
270
|
+
"""Flag Dashboard LaunchAgent contradicting Desktop product surface.
|
|
271
|
+
|
|
272
|
+
Contract (see ``docs/f06-layout-contract.md`` §4):
|
|
273
|
+
- Terminal-only install → ``com.nexo.dashboard`` loaded.
|
|
274
|
+
- Desktop-managed install → ``com.nexo.dashboard`` unloaded.
|
|
275
|
+
Both signals disagreeing with the chosen product mode is a warn.
|
|
276
|
+
"""
|
|
277
|
+
if sys.platform != "darwin":
|
|
278
|
+
return DoctorCheck(
|
|
279
|
+
id="boot.dashboard_desktop_contract",
|
|
280
|
+
tier="boot",
|
|
281
|
+
status="healthy",
|
|
282
|
+
severity="info",
|
|
283
|
+
summary="Non-darwin host — dashboard LaunchAgent contract does not apply",
|
|
284
|
+
)
|
|
285
|
+
agent_path = Path.home() / "Library" / "LaunchAgents" / "com.nexo.dashboard.plist"
|
|
286
|
+
agent_installed = agent_path.exists()
|
|
287
|
+
try:
|
|
288
|
+
from product_mode import enforce_desktop_product_contract # type: ignore
|
|
289
|
+
desktop_contract = bool(enforce_desktop_product_contract())
|
|
290
|
+
except Exception:
|
|
291
|
+
desktop_contract = False
|
|
292
|
+
|
|
293
|
+
if desktop_contract and agent_installed:
|
|
294
|
+
return DoctorCheck(
|
|
295
|
+
id="boot.dashboard_desktop_contract",
|
|
296
|
+
tier="boot",
|
|
297
|
+
status="degraded",
|
|
298
|
+
severity="warn",
|
|
299
|
+
summary="Desktop product surface active but standalone dashboard LaunchAgent is installed",
|
|
300
|
+
evidence=[f"Plist: {agent_path}"],
|
|
301
|
+
repair_plan=[
|
|
302
|
+
f"launchctl unload {agent_path}",
|
|
303
|
+
f"rm {agent_path}",
|
|
304
|
+
],
|
|
305
|
+
)
|
|
306
|
+
if not desktop_contract and not agent_installed:
|
|
307
|
+
return DoctorCheck(
|
|
308
|
+
id="boot.dashboard_desktop_contract",
|
|
309
|
+
tier="boot",
|
|
310
|
+
status="degraded",
|
|
311
|
+
severity="warn",
|
|
312
|
+
summary="Terminal-only install without a dashboard LaunchAgent",
|
|
313
|
+
evidence=["Expected plist missing: com.nexo.dashboard.plist"],
|
|
314
|
+
repair_plan=["nexo update # re-materialize com.nexo.dashboard"],
|
|
315
|
+
)
|
|
316
|
+
return DoctorCheck(
|
|
317
|
+
id="boot.dashboard_desktop_contract",
|
|
318
|
+
tier="boot",
|
|
319
|
+
status="healthy",
|
|
320
|
+
severity="info",
|
|
321
|
+
summary="Dashboard LaunchAgent state matches the product surface contract",
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def check_f06_migration_consistency() -> DoctorCheck:
|
|
326
|
+
"""Detect half-migrated F0.6 installs.
|
|
327
|
+
|
|
328
|
+
Contract (``docs/f06-layout-contract.md`` §6 rule 5):
|
|
329
|
+
- F0.6 marker + legacy runtime dirs populated → half-migration.
|
|
330
|
+
- No marker but canonical ``core/`` already populated → half-migration.
|
|
331
|
+
- Marker F0.6 with no legacy residue → healthy.
|
|
332
|
+
- No marker, no canonical ``core/``, pure legacy layout → healthy
|
|
333
|
+
(pre-F0.6 install waiting for ``nexo update``).
|
|
334
|
+
|
|
335
|
+
Half-migration is the scenario where ``paths.coordination_dir()`` (and
|
|
336
|
+
siblings) silently fall back to the legacy path on an install that
|
|
337
|
+
*should* be on F0.6. Doctor surfaces it so ``nexo update`` can be
|
|
338
|
+
asked to finish the job instead of the operator discovering later that
|
|
339
|
+
half their state lives in the wrong place.
|
|
340
|
+
"""
|
|
341
|
+
import paths
|
|
342
|
+
marker = NEXO_HOME / ".structure-version"
|
|
343
|
+
marker_text = ""
|
|
344
|
+
if marker.is_file():
|
|
345
|
+
try:
|
|
346
|
+
marker_text = marker.read_text().strip().upper().split()[0]
|
|
347
|
+
except (OSError, IndexError):
|
|
348
|
+
marker_text = ""
|
|
349
|
+
is_f06_marked = marker_text.startswith("F0.6")
|
|
350
|
+
|
|
351
|
+
core_dir = paths.core_dir()
|
|
352
|
+
core_populated = core_dir.is_dir() and any(core_dir.iterdir()) if core_dir.exists() else False
|
|
353
|
+
|
|
354
|
+
# Legacy runtime dirs that MUST be gone (or be symlinks into canonical F0.6)
|
|
355
|
+
# once the migration has finished physically.
|
|
356
|
+
legacy_runtime_names = ("coordination", "data", "logs", "operations")
|
|
357
|
+
legacy_stragglers: list[str] = []
|
|
358
|
+
for name in legacy_runtime_names:
|
|
359
|
+
legacy_path = NEXO_HOME / name
|
|
360
|
+
if not legacy_path.exists():
|
|
361
|
+
continue
|
|
362
|
+
if legacy_path.is_symlink():
|
|
363
|
+
# A symlink pointing at the canonical runtime/<name> is the
|
|
364
|
+
# compat shim contract; that is acceptable.
|
|
365
|
+
continue
|
|
366
|
+
try:
|
|
367
|
+
has_content = any(legacy_path.iterdir())
|
|
368
|
+
except OSError:
|
|
369
|
+
has_content = False
|
|
370
|
+
if has_content:
|
|
371
|
+
legacy_stragglers.append(name)
|
|
372
|
+
|
|
373
|
+
if is_f06_marked and legacy_stragglers:
|
|
374
|
+
return DoctorCheck(
|
|
375
|
+
id="boot.f06_migration_consistency",
|
|
376
|
+
tier="boot",
|
|
377
|
+
status="critical",
|
|
378
|
+
severity="error",
|
|
379
|
+
summary="Half-migrated F0.6 install: marker present but legacy runtime dirs still populated",
|
|
380
|
+
evidence=[f"Marker: {marker_text}"] + [f"Legacy with content: {NEXO_HOME / n}" for n in legacy_stragglers],
|
|
381
|
+
repair_plan=[
|
|
382
|
+
"nexo update # finish the F0.6 migration",
|
|
383
|
+
"# if update refuses, inspect manifest and consider: nexo rollback f06",
|
|
384
|
+
],
|
|
385
|
+
)
|
|
386
|
+
if (not is_f06_marked) and core_populated:
|
|
387
|
+
return DoctorCheck(
|
|
388
|
+
id="boot.f06_migration_consistency",
|
|
389
|
+
tier="boot",
|
|
390
|
+
status="critical",
|
|
391
|
+
severity="error",
|
|
392
|
+
summary="Half-migrated F0.6 install: core/ populated but marker absent",
|
|
393
|
+
evidence=[f"Marker: {marker_text or '(absent)'}", f"core/ path: {core_dir}"],
|
|
394
|
+
repair_plan=[
|
|
395
|
+
"nexo update # re-run migration to write the marker",
|
|
396
|
+
],
|
|
397
|
+
)
|
|
398
|
+
return DoctorCheck(
|
|
399
|
+
id="boot.f06_migration_consistency",
|
|
400
|
+
tier="boot",
|
|
401
|
+
status="healthy",
|
|
402
|
+
severity="info",
|
|
403
|
+
summary=(
|
|
404
|
+
f"F0.6 marker consistent with layout (marker={marker_text or 'absent'}, "
|
|
405
|
+
f"legacy_stragglers={len(legacy_stragglers)})"
|
|
406
|
+
),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
223
410
|
def run_boot_checks(fix: bool = False) -> list[DoctorCheck]:
|
|
224
411
|
"""Run all boot-tier checks."""
|
|
225
412
|
checks = [
|
|
@@ -229,6 +416,9 @@ def run_boot_checks(fix: bool = False) -> list[DoctorCheck]:
|
|
|
229
416
|
safe_check(check_wrapper_scripts),
|
|
230
417
|
safe_check(check_python_runtime),
|
|
231
418
|
safe_check(check_config_parse),
|
|
419
|
+
safe_check(check_core_dev_packaged_install),
|
|
420
|
+
safe_check(check_dashboard_desktop_contract),
|
|
421
|
+
safe_check(check_f06_migration_consistency),
|
|
232
422
|
]
|
|
233
423
|
|
|
234
424
|
if fix:
|
package/src/evolution_cycle.py
CHANGED
|
@@ -19,6 +19,10 @@ from core_prompts import render_core_prompt
|
|
|
19
19
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
20
20
|
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(NEXO_HOME)))
|
|
21
21
|
NEXO_DB = paths.db_path()
|
|
22
|
+
# Evolution sandbox lives under the runtime root (equivalent to
|
|
23
|
+
# ``paths.runtime_dir() / "sandbox"``). Kept as ``NEXO_HOME / sandbox /
|
|
24
|
+
# workspace`` for backwards compatibility with existing installs that already
|
|
25
|
+
# have a populated sandbox at this path. Do NOT relocate without a migration.
|
|
22
26
|
SANDBOX_DIR = NEXO_HOME / "sandbox" / "workspace"
|
|
23
27
|
SNAPSHOTS_DIR = paths.snapshots_dir()
|
|
24
28
|
RESTORE_LOG = paths.logs_dir() / "snapshot-restores.log"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Guardian runtime config resolver — single source of truth for enforcer flags.
|
|
2
|
+
|
|
3
|
+
v7.2.0 introduces ``~/.nexo/personal/config/guardian-runtime-overrides.json``
|
|
4
|
+
as the persistent operator-owned default for Guardian gate modes:
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"G1_ENFORCER_ACTIVE": "hard",
|
|
8
|
+
"G3_ENFORCE_DESTRUCTIVE": "hard",
|
|
9
|
+
"G3_SSH_ENFORCE_REMOTE_WRITE": "hard",
|
|
10
|
+
"G4_ENFORCE_GUARD_CHECK": "hard"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
The JSON values match the ``NEXO_<FLAG>`` env-var semantics exactly
|
|
14
|
+
(``off`` / ``shadow`` / ``hard``). Env vars always win over the file so
|
|
15
|
+
an ad-hoc ``NEXO_G4_ENFORCE_GUARD_CHECK=shadow`` during debugging still
|
|
16
|
+
takes effect. The file is loaded lazily and cached per-process; callers
|
|
17
|
+
should not rely on edits to take effect without a restart.
|
|
18
|
+
|
|
19
|
+
Public API:
|
|
20
|
+
``resolve_guardian_flag(name, default='shadow')`` -> normalized value.
|
|
21
|
+
|
|
22
|
+
``name`` is the short key (``G1_ENFORCER_ACTIVE``) without the ``NEXO_``
|
|
23
|
+
prefix. Resolution order:
|
|
24
|
+
1. Environment variable ``NEXO_<name>`` if set and non-empty.
|
|
25
|
+
2. Override file entry.
|
|
26
|
+
3. ``default``.
|
|
27
|
+
|
|
28
|
+
All returned values are lowercased and whitespace-stripped.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_CACHE: dict[str, str] | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _overrides_path() -> Path:
|
|
41
|
+
home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
42
|
+
return home / "personal" / "config" / "guardian-runtime-overrides.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_overrides() -> dict[str, str]:
|
|
46
|
+
global _CACHE
|
|
47
|
+
if _CACHE is not None:
|
|
48
|
+
return _CACHE
|
|
49
|
+
path = _overrides_path()
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
_CACHE = {}
|
|
52
|
+
return _CACHE
|
|
53
|
+
try:
|
|
54
|
+
raw = json.loads(path.read_text())
|
|
55
|
+
except Exception:
|
|
56
|
+
_CACHE = {}
|
|
57
|
+
return _CACHE
|
|
58
|
+
if not isinstance(raw, dict):
|
|
59
|
+
_CACHE = {}
|
|
60
|
+
return _CACHE
|
|
61
|
+
normalized: dict[str, str] = {}
|
|
62
|
+
for key, value in raw.items():
|
|
63
|
+
if not isinstance(value, str):
|
|
64
|
+
continue
|
|
65
|
+
normalized[str(key).strip().upper()] = value.strip().lower()
|
|
66
|
+
_CACHE = normalized
|
|
67
|
+
return _CACHE
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def invalidate_cache() -> None:
|
|
71
|
+
"""Drop the cached override file so the next call re-reads from disk.
|
|
72
|
+
|
|
73
|
+
Intended for tests and for the updater right after it writes a new
|
|
74
|
+
version of the file. Production code should not need to call this.
|
|
75
|
+
"""
|
|
76
|
+
global _CACHE
|
|
77
|
+
_CACHE = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_guardian_flag(name: str, default: str = "shadow") -> str:
|
|
81
|
+
"""Resolve a Guardian gate mode (``off`` / ``shadow`` / ``hard``).
|
|
82
|
+
|
|
83
|
+
Env var ``NEXO_<name>`` has priority; falls back to the overrides
|
|
84
|
+
file; falls back to ``default`` last.
|
|
85
|
+
"""
|
|
86
|
+
clean = str(name or "").strip().upper()
|
|
87
|
+
if not clean:
|
|
88
|
+
return str(default or "shadow").strip().lower()
|
|
89
|
+
|
|
90
|
+
env_value = os.environ.get(f"NEXO_{clean}", "").strip()
|
|
91
|
+
if env_value:
|
|
92
|
+
return env_value.lower()
|
|
93
|
+
|
|
94
|
+
file_value = _load_overrides().get(clean, "")
|
|
95
|
+
if file_value:
|
|
96
|
+
return file_value
|
|
97
|
+
|
|
98
|
+
return str(default or "shadow").strip().lower()
|
package/src/hook_guardrails.py
CHANGED
|
@@ -156,6 +156,82 @@ def _classify_destructive_intent(command: str) -> str | None:
|
|
|
156
156
|
return None
|
|
157
157
|
|
|
158
158
|
|
|
159
|
+
# Block K G3 (SSH wrapper): remote-write patterns the local destructive
|
|
160
|
+
# gate never sees because they run inside ``ssh host '...'`` / rsync /
|
|
161
|
+
# scp / sftp. Matched against the raw Bash command string.
|
|
162
|
+
# Each regex aims to catch a well-known write primitive *inside* a remote
|
|
163
|
+
# invocation. ``ssh host 'ls'`` must not match — only write-verbs do.
|
|
164
|
+
_SSH_REMOTE_SHELL_RE = re.compile(
|
|
165
|
+
r"\bssh\b[^'\"`]*?(?:['\"`])(?P<remote>[^'\"`]+)(?:['\"`])",
|
|
166
|
+
re.IGNORECASE,
|
|
167
|
+
)
|
|
168
|
+
_SSH_REMOTE_WRITE_VERBS = (
|
|
169
|
+
re.compile(r"^\s*cat\s*>\s*\S", re.IGNORECASE), # cat > file
|
|
170
|
+
re.compile(r"^\s*cat\s*>>\s*\S", re.IGNORECASE), # cat >> file
|
|
171
|
+
re.compile(r"\btee\s+(?:-\S+\s+)*[^\s|&;]+", re.IGNORECASE), # tee [-a] file
|
|
172
|
+
re.compile(r"^\s*(?:echo|printf)\s+.*\s+>>?\s*\S", re.IGNORECASE), # echo ... > file
|
|
173
|
+
re.compile(r"\bsed\s+-i\b", re.IGNORECASE), # sed -i ...
|
|
174
|
+
re.compile(r"(?:^|\s)>\s*\S", re.IGNORECASE), # bare > file
|
|
175
|
+
re.compile(r"(?:^|\s)>>\s*\S", re.IGNORECASE), # bare >> file
|
|
176
|
+
re.compile(r"\brm\s+-\S*[rRfF]", re.IGNORECASE), # remote rm -rf
|
|
177
|
+
re.compile(r"\bmv\s+\S+\s+\S+", re.IGNORECASE), # remote mv
|
|
178
|
+
re.compile(r"\bcp\s+\S+\s+\S+", re.IGNORECASE), # remote cp
|
|
179
|
+
)
|
|
180
|
+
_SCP_WRITE_RE = re.compile(
|
|
181
|
+
r"\bscp\b[^|&;]*?\s\S+\s+[^:\s]+:[^\s]+",
|
|
182
|
+
re.IGNORECASE,
|
|
183
|
+
)
|
|
184
|
+
_RSYNC_WRITE_RE = re.compile(
|
|
185
|
+
r"\brsync\b[^|&;]*?\s\S+\s+[^:\s]+:[^\s]+",
|
|
186
|
+
re.IGNORECASE,
|
|
187
|
+
)
|
|
188
|
+
_SFTP_BATCH_RE = re.compile(
|
|
189
|
+
r"\bsftp\b(?:[^|&;]*\s)?-b\s+\S+",
|
|
190
|
+
re.IGNORECASE,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _classify_ssh_remote_write(command: str) -> str | None:
|
|
195
|
+
"""Return the matching pattern name when ``command`` writes to a remote host.
|
|
196
|
+
|
|
197
|
+
Covers four shapes:
|
|
198
|
+
1. ``ssh host '<remote-shell-that-writes>'`` with or without
|
|
199
|
+
``-o`` flags, using single/double/backtick quoting.
|
|
200
|
+
2. ``scp LOCAL_PATH host:REMOTE_PATH`` (upload direction).
|
|
201
|
+
3. ``rsync [opts] LOCAL_PATH host:REMOTE_PATH`` (upload direction).
|
|
202
|
+
4. ``sftp -b batchfile host`` (any -b invocation is considered a
|
|
203
|
+
write candidate because the batch may mutate).
|
|
204
|
+
|
|
205
|
+
Ignores read-only invocations such as ``ssh host 'ls /etc'`` or
|
|
206
|
+
``scp host:REMOTE /local/`` (download), which is the common case.
|
|
207
|
+
"""
|
|
208
|
+
cmd = str(command or "")
|
|
209
|
+
|
|
210
|
+
if _SCP_WRITE_RE.search(cmd):
|
|
211
|
+
# Disambiguate download (host:remote local) vs upload (local host:remote).
|
|
212
|
+
# Simple rule: if the FIRST ``host:path`` argument is preceded by a
|
|
213
|
+
# local-looking arg, treat as upload.
|
|
214
|
+
download = re.search(r"\bscp\b[^|&;]*?\s[^:\s]+:\S+\s+\S+", cmd)
|
|
215
|
+
if not download:
|
|
216
|
+
return "scp_remote_write"
|
|
217
|
+
if _RSYNC_WRITE_RE.search(cmd):
|
|
218
|
+
download = re.search(r"\brsync\b[^|&;]*?\s[^:\s]+:\S+\s+\S+", cmd)
|
|
219
|
+
if not download:
|
|
220
|
+
return "rsync_remote_write"
|
|
221
|
+
if _SFTP_BATCH_RE.search(cmd):
|
|
222
|
+
return "sftp_batch_remote_write"
|
|
223
|
+
|
|
224
|
+
for match in _SSH_REMOTE_SHELL_RE.finditer(cmd):
|
|
225
|
+
remote = match.group("remote") or ""
|
|
226
|
+
# Strip leading "sudo ", "env VAR=x ", "cd dir &&" — they never mean
|
|
227
|
+
# a write by themselves, and may hide real writes behind them.
|
|
228
|
+
trimmed = re.sub(r"^\s*(?:sudo\s+|env\s+\S+=\S+\s+|cd\s+\S+\s*&&\s*)+", "", remote)
|
|
229
|
+
for pattern in _SSH_REMOTE_WRITE_VERBS:
|
|
230
|
+
if pattern.search(trimmed):
|
|
231
|
+
return "ssh_remote_shell_write"
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
159
235
|
def _operation_kind(tool_name: str) -> str:
|
|
160
236
|
if tool_name in READ_LIKE_TOOLS:
|
|
161
237
|
return "read"
|
|
@@ -701,12 +777,18 @@ def _collect_protocol_warnings(conn, *, sid: str, tool_name: str) -> list[dict]:
|
|
|
701
777
|
if task.get("must_change_log")
|
|
702
778
|
else ""
|
|
703
779
|
)
|
|
780
|
+
closeout_note = (
|
|
781
|
+
" If this edit wave came from a user correction or you are leaving a blocker unresolved, "
|
|
782
|
+
"include `correction_happened=true` with a reusable learning, or `followup_needed=true`, "
|
|
783
|
+
"when you call `nexo_task_close(...)`."
|
|
784
|
+
)
|
|
704
785
|
_append_protocol_warning(
|
|
705
786
|
warnings,
|
|
706
787
|
render_core_prompt(
|
|
707
788
|
"hook-protocol-warning-task-close-evidence",
|
|
708
789
|
task_id=task_id,
|
|
709
790
|
change_note=change_note,
|
|
791
|
+
closeout_note=closeout_note,
|
|
710
792
|
),
|
|
711
793
|
)
|
|
712
794
|
|
|
@@ -1005,7 +1087,11 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
1005
1087
|
# NEXO_G3_ENFORCE_DESTRUCTIVE (default "shadow"): shadow records a
|
|
1006
1088
|
# warn-severity debt for observability; hard blocks the operation
|
|
1007
1089
|
# with error severity; off disables the gate entirely.
|
|
1008
|
-
|
|
1090
|
+
try:
|
|
1091
|
+
from guardian_runtime_config import resolve_guardian_flag
|
|
1092
|
+
g3_mode = resolve_guardian_flag("G3_ENFORCE_DESTRUCTIVE", default="shadow")
|
|
1093
|
+
except Exception:
|
|
1094
|
+
g3_mode = os.environ.get("NEXO_G3_ENFORCE_DESTRUCTIVE", "shadow").strip().lower()
|
|
1009
1095
|
if g3_mode in {"shadow", "hard"} and tool_name == "Bash":
|
|
1010
1096
|
shell_command = _extract_bash_command(tool_input)
|
|
1011
1097
|
destructive_pattern = _classify_destructive_intent(shell_command)
|
|
@@ -1047,6 +1133,62 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
1047
1133
|
"g3_mode": g3_mode,
|
|
1048
1134
|
}
|
|
1049
1135
|
|
|
1136
|
+
# Block K G3 SSH wrapper (Francisco 2026-04-22 v7.2.0): remote-write
|
|
1137
|
+
# commands routed through ssh/rsync/scp/sftp never reach the local
|
|
1138
|
+
# destructive gate. Gated by NEXO_G3_SSH_ENFORCE_REMOTE_WRITE (default
|
|
1139
|
+
# "shadow") mirroring the destructive-local flag shape. Shadow logs a
|
|
1140
|
+
# warn debt row; hard blocks with error severity; off disables.
|
|
1141
|
+
try:
|
|
1142
|
+
from guardian_runtime_config import resolve_guardian_flag
|
|
1143
|
+
g3_ssh_mode = resolve_guardian_flag(
|
|
1144
|
+
"G3_SSH_ENFORCE_REMOTE_WRITE", default="shadow"
|
|
1145
|
+
)
|
|
1146
|
+
except Exception:
|
|
1147
|
+
g3_ssh_mode = os.environ.get(
|
|
1148
|
+
"NEXO_G3_SSH_ENFORCE_REMOTE_WRITE", "shadow"
|
|
1149
|
+
).strip().lower()
|
|
1150
|
+
if g3_ssh_mode in {"shadow", "hard"} and tool_name == "Bash":
|
|
1151
|
+
shell_command = _extract_bash_command(tool_input)
|
|
1152
|
+
ssh_pattern = _classify_ssh_remote_write(shell_command)
|
|
1153
|
+
if ssh_pattern:
|
|
1154
|
+
severity = "error" if g3_ssh_mode == "hard" else "warn"
|
|
1155
|
+
debt = _ensure_protocol_debt(
|
|
1156
|
+
conn,
|
|
1157
|
+
session_id=sid,
|
|
1158
|
+
task_id="",
|
|
1159
|
+
debt_type="g3_ssh_remote_write_requires_cortex",
|
|
1160
|
+
severity=severity,
|
|
1161
|
+
evidence=(
|
|
1162
|
+
f"Bash command matched SSH remote-write pattern '{ssh_pattern}'. "
|
|
1163
|
+
f"Command head: {shell_command[:160]}. "
|
|
1164
|
+
"Run nexo_cortex_decide (or nexo_task_open for the session) "
|
|
1165
|
+
"and record evidence before retrying."
|
|
1166
|
+
),
|
|
1167
|
+
file_token=ssh_pattern,
|
|
1168
|
+
)
|
|
1169
|
+
if g3_ssh_mode == "hard":
|
|
1170
|
+
return {
|
|
1171
|
+
"ok": True,
|
|
1172
|
+
"session_id": sid,
|
|
1173
|
+
"tool_name": tool_name,
|
|
1174
|
+
"operation": op,
|
|
1175
|
+
"strictness": strictness,
|
|
1176
|
+
"blocks": [
|
|
1177
|
+
{
|
|
1178
|
+
"file": "",
|
|
1179
|
+
"task_id": "",
|
|
1180
|
+
"debt_id": debt.get("id"),
|
|
1181
|
+
"debt_type": "g3_ssh_remote_write_requires_cortex",
|
|
1182
|
+
"reason_code": "g3_ssh_remote_write_blocked",
|
|
1183
|
+
"severity": "error",
|
|
1184
|
+
"pattern": ssh_pattern,
|
|
1185
|
+
"g3_ssh_mode": g3_ssh_mode,
|
|
1186
|
+
}
|
|
1187
|
+
],
|
|
1188
|
+
"status": "blocked",
|
|
1189
|
+
"g3_ssh_mode": g3_ssh_mode,
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1050
1192
|
# Block K G4 (Francisco 2026-04-22): require nexo_guard_check to have
|
|
1051
1193
|
# run within the session for every file about to be written. Opt-in
|
|
1052
1194
|
# via NEXO_G4_ENFORCE_GUARD_CHECK (default "shadow"): ``shadow``
|
|
@@ -1055,7 +1197,11 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
1055
1197
|
# so the operator must run guard_check explicitly. Skipped entirely
|
|
1056
1198
|
# in lenient mode or when there are no files, since the existing
|
|
1057
1199
|
# strict-mode path already covers those cases with its own gating.
|
|
1058
|
-
|
|
1200
|
+
try:
|
|
1201
|
+
from guardian_runtime_config import resolve_guardian_flag
|
|
1202
|
+
g4_mode = resolve_guardian_flag("G4_ENFORCE_GUARD_CHECK", default="shadow")
|
|
1203
|
+
except Exception:
|
|
1204
|
+
g4_mode = os.environ.get("NEXO_G4_ENFORCE_GUARD_CHECK", "shadow").strip().lower()
|
|
1059
1205
|
if g4_mode in {"shadow", "hard"} and files and sid:
|
|
1060
1206
|
g4_blocks: list[dict] = []
|
|
1061
1207
|
g4_warnings: list[dict] = []
|