Compare commits

...

4 Commits

Author SHA1 Message Date
dependabot[bot] 90dbd7327b chore(deps): bump the cargo-minor-and-patch group across 1 directory with 3 updates
Bumps the cargo-minor-and-patch group with 3 updates in the /ecc2 directory: [ratatui](https://github.com/ratatui/ratatui), [anyhow](https://github.com/dtolnay/anyhow) and [uuid](https://github.com/uuid-rs/uuid).


Updates `ratatui` from 0.30.1 to 0.30.2
- [Release notes](https://github.com/ratatui/ratatui/releases)
- [Changelog](https://github.com/ratatui/ratatui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ratatui/ratatui/compare/ratatui-v0.30.1...ratatui-v0.30.2)

Updates `anyhow` from 1.0.102 to 1.0.103
- [Release notes](https://github.com/dtolnay/anyhow/releases)
- [Commits](https://github.com/dtolnay/anyhow/compare/1.0.102...1.0.103)

Updates `uuid` from 1.23.3 to 1.23.4
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.23.3...v1.23.4)

---
updated-dependencies:
- dependency-name: ratatui
  dependency-version: 0.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-and-patch
- dependency-name: anyhow
  dependency-version: 1.0.103
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-and-patch
- dependency-name: uuid
  dependency-version: 1.23.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-29 04:55:36 +00:00
Affaan Mustafa 2bc924faf2 fix(clv2): harden registry writes and project deletion (#2294, #2297) (#2323)
Two security-priority fixes in continuous-learning-v2/scripts/instinct-cli.py:

- #2294: _write_registry wrote projects.json without the advisory lock that
  _update_registry holds, so concurrent 'projects delete/gc/merge' could race an
  observe-time update and corrupt the registry. Extract the lock into a shared
  _registry_lock() context manager and use it in both writers.

- #2297: _remove_project_storage called shutil.rmtree on PROJECTS_DIR/project_id
  with no containment check. Add defense-in-depth: resolve the path and refuse to
  delete anything that is not strictly inside PROJECTS_DIR (or is the root
  itself), so a relaxed validator or future caller can never cause an
  arbitrary-directory delete.

Adds 5 pytest regression tests (atomic write under lock, contained delete,
missing-dir no-op, traversal refused, root refused). Node integration suite
(tests/scripts/instinct-cli-projects.test.js) green 9/9.
2026-06-25 16:47:35 -07:00
Gaurav Dubey e3f467989a fix(clv2): escape $HOME before pgrep -f in migrate-homunculus.sh (#2339)
* fix(clv2): escape $HOME before pgrep -f in migrate-homunculus.sh

pgrep -f treats its argument as an extended regular expression, but the
running-observer guard interpolated $HOME unescaped. Paths containing regex
metacharacters (e.g. /home/user.name, /home/c++dev, /home/user (work)) made the
match over-broad or invalid, causing either a false negative (live observer
missed, migration proceeds and risks registry corruption) or a false positive
(migration blocked unnecessarily).

Escape the ERE metacharacters in $HOME via sed before building the pattern so
the home prefix is matched literally while the trailing .*observer-loop\.sh
regex is preserved. Portable across BSD and GNU sed.

Fixes #2301

* test(clv2): add regression test for migrate-homunculus.sh $HOME escaping

Guards the #2301 fix: extracts the script's sed escaping command and asserts
the resulting pgrep -f pattern matches the literal home path while no longer
over-matching a regex-expanded decoy (HOME=/home/user.name must not match
/home/userXname). Also pins that the guard uses escaped_home rather than $HOME
directly. Follows the existing clv2 shell-test convention in
tests/hooks/observe-entrypoint-allowlist.test.js.

Refs #2301

* test(clv2): skip migrate-homunculus escaping test on Windows

The test relies on POSIX bash/sed/grep -E semantics, which differ on the
Windows CI runners. Guard with the same process.platform === 'win32' early
exit used by tests/hooks/observe-subdirectory-detection.test.js so the
bash-dependent assertions only run on POSIX platforms.

Refs #2301
2026-06-25 16:47:32 -07:00
Affaan Mustafa e8244d9ced feat(control-pane): serve 3D agent-airspace viz + /api/proximity feed (#2320)
Adds the Layer 4 observability view to the control pane: a self-contained,
dependency-free 3D point-cloud of the agent airspace (positions from the
proximity embedding, sized by working set, colored by collision risk, links
for converging pairs) plus an XSS-safe advisory panel that polls every 5s.

- proximity-viz.js: renderProximityVizHtml() (canvas projection, no external JS)
- server.js: GET /proximity (page) + GET /api/proximity (snapshot.proximity feed)
- test: asserts both routes serve and the feed carries positions/links/advisories
2026-06-25 16:45:53 -07:00
8 changed files with 542 additions and 46 deletions
+54 -21
View File
@@ -84,9 +84,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "approx"
@@ -386,7 +386,7 @@ dependencies = [
"chrono",
"once_cell",
"phf",
"winnow",
"winnow 0.7.15",
]
[[package]]
@@ -1654,14 +1654,15 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "ratatui"
version = "0.30.1"
version = "0.30.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68"
checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
dependencies = [
"instability",
"ratatui-core",
"ratatui-crossterm",
"ratatui-macros",
"ratatui-termina",
"ratatui-termwiz",
"ratatui-widgets",
"serde",
@@ -1669,15 +1670,14 @@ dependencies = [
[[package]]
name = "ratatui-core"
version = "0.1.1"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44"
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
dependencies = [
"bitflags 2.13.0",
"compact_str",
"critical-section",
"hashbrown 0.17.1",
"indoc",
"itertools",
"kasuari",
"lru",
@@ -1692,9 +1692,9 @@ dependencies = [
[[package]]
name = "ratatui-crossterm"
version = "0.1.1"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c"
checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
dependencies = [
"cfg-if",
"crossterm",
@@ -1704,19 +1704,30 @@ dependencies = [
[[package]]
name = "ratatui-macros"
version = "0.7.1"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80fac59720679490d89d200df411faa249be728681adcabed3d047ae72c48f1d"
checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814"
dependencies = [
"ratatui-core",
"ratatui-widgets",
]
[[package]]
name = "ratatui-termwiz"
version = "0.1.1"
name = "ratatui-termina"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967"
checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2"
dependencies = [
"instability",
"ratatui-core",
"termina",
]
[[package]]
name = "ratatui-termwiz"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977"
dependencies = [
"ratatui-core",
"termwiz",
@@ -1724,9 +1735,9 @@ dependencies = [
[[package]]
name = "ratatui-widgets"
version = "0.3.1"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c"
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
dependencies = [
"bitflags 2.13.0",
"hashbrown 0.17.1",
@@ -2149,6 +2160,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "termina"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e"
dependencies = [
"bitflags 2.13.0",
"parking_lot",
"rustix",
"signal-hook",
"windows-sys 0.61.2",
]
[[package]]
name = "terminfo"
version = "0.9.0"
@@ -2344,7 +2368,7 @@ dependencies = [
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
"winnow 1.0.3",
]
[[package]]
@@ -2362,7 +2386,7 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
"winnow 1.0.3",
]
[[package]]
@@ -2549,9 +2573,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.3"
version = "1.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
dependencies = [
"atomic",
"getrandom 0.4.2",
@@ -2933,6 +2957,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.3"
+191
View File
@@ -0,0 +1,191 @@
'use strict';
/**
* Self-contained 3D "agent airspace" visualization, served by the control pane.
*
* Renders each agent as a point in code-space (positions from the proximity
* embedding), sized by working-set size and colored by collision risk, with
* links between converging pairs (amber = transmit advisory, red = steer). The
* scene auto-rotates so you can read the cloud. Dependency-free: a hand-rolled
* 3D2D projection on a <canvas>, no external scripts (CSP/offline friendly).
*
* This is the operator/Enterprise view of Layer 4: multi-agent observability:
* literally watch the swarm and watch one agent steer away from a collision.
*/
function renderProximityVizHtml() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ECC Agent Airspace</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.4 -apple-system, system-ui, sans-serif; background: #0b0e14; color: #e6edf3; }
header { display: flex; align-items: baseline; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #1f2630; }
header h1 { font-size: 15px; margin: 0; }
header .sub { color: #8b949e; font-size: 12px; }
#wrap { display: grid; grid-template-columns: 1fr 320px; height: calc(100vh - 49px); }
#stage { position: relative; }
canvas { width: 100%; height: 100%; display: block; }
#side { border-left: 1px solid #1f2630; padding: 12px 14px; overflow-y: auto; }
#side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 0 0 8px; }
.adv { border: 1px solid #1f2630; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }
.adv.resolution { border-color: #b3402f; }
.adv.advisory { border-color: #9a6700; }
.adv .lv { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
.adv.resolution .lv { color: #ff7b72; }
.adv.advisory .lv { color: #e3b341; }
.adv .who { color: #c9d1d9; }
.adv .act { color: #8b949e; font-size: 12px; margin-top: 3px; }
.empty { color: #6e7681; }
#legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
</style>
</head>
<body>
<header>
<h1>ECC - Agent Airspace</h1>
<span class="sub" id="status">connecting...</span>
</header>
<div id="wrap">
<div id="stage">
<canvas id="c"></canvas>
<div id="legend">
<div><span class="dot" style="background:#3fb950"></span>clear</div>
<div><span class="dot" style="background:#e3b341"></span>traffic advisory (transmit)</div>
<div><span class="dot" style="background:#ff7b72"></span>resolution (steer)</div>
</div>
</div>
<div id="side">
<h2>Advisories</h2>
<div id="advisories"><div class="empty">No advisories - airspace clear.</div></div>
</div>
</div>
<script>
(function () {
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
var state = { positions: [], links: [], advisories: [], riskByAgent: {} };
var angle = 0;
function resize() {
var r = canvas.parentElement.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(r.width * dpr));
canvas.height = Math.max(1, Math.floor(r.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
function riskColor(risk) {
if (risk >= 0.7) return '#ff7b72';
if (risk >= 0.35) return '#e3b341';
return '#3fb950';
}
// 3D to 2D: rotate around Y, simple perspective.
function project(p, w, h) {
var x = p[0], y = p[1] || 0, z = p[2] || 0;
var ca = Math.cos(angle), sa = Math.sin(angle);
var rx = x * ca - z * sa;
var rz = x * sa + z * ca;
var scale = 2.4 / (3.2 + rz); // perspective
return [w / 2 + rx * scale * (Math.min(w, h) * 0.32), h / 2 + y * scale * (Math.min(w, h) * 0.32), scale];
}
function draw() {
var w = canvas.clientWidth, h = canvas.clientHeight;
ctx.clearRect(0, 0, w, h);
var pos = {};
for (var i = 0; i < state.positions.length; i++) {
var a = state.positions[i];
pos[a.agentId] = project(a.position || [0, 0, 0], w, h);
}
// links first (under the points)
for (var l = 0; l < state.links.length; l++) {
var link = state.links[l];
if (link.risk < 0.2) continue;
var pa = pos[link.a], pb = pos[link.b];
if (!pa || !pb) continue;
ctx.strokeStyle = riskColor(link.risk);
ctx.globalAlpha = Math.min(1, 0.25 + link.risk * 0.7);
ctx.lineWidth = 1 + link.risk * 3;
ctx.beginPath(); ctx.moveTo(pa[0], pa[1]); ctx.lineTo(pb[0], pb[1]); ctx.stroke();
}
ctx.globalAlpha = 1;
// points
for (var k = 0; k < state.positions.length; k++) {
var ag = state.positions[k];
var p = pos[ag.agentId];
var radius = (6 + Math.sqrt(ag.fileCount || 1) * 3) * p[2];
ctx.fillStyle = riskColor(state.riskByAgent[ag.agentId] || 0);
ctx.beginPath(); ctx.arc(p[0], p[1], radius, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#c9d1d9';
ctx.font = '11px -apple-system, system-ui, sans-serif';
ctx.fillText(String(ag.agentId).slice(0, 18), p[0] + radius + 4, p[1] + 3);
}
angle += 0.0035;
requestAnimationFrame(draw);
}
function renderAdvisories() {
var box = document.getElementById('advisories');
box.textContent = '';
if (!state.advisories.length) {
var e = document.createElement('div'); e.className = 'empty';
e.textContent = 'No advisories - airspace clear.'; box.appendChild(e); return;
}
state.advisories.forEach(function (adv) {
var el = document.createElement('div');
el.className = 'adv ' + (adv.level === 'resolution' ? 'resolution' : 'advisory');
var lv = document.createElement('div'); lv.className = 'lv';
lv.textContent = Math.round(adv.risk * 100) + '% - ' + adv.level; el.appendChild(lv);
var who = document.createElement('div'); who.className = 'who';
who.textContent = (adv.aLabel || adv.a) + ' <-> ' + (adv.bLabel || adv.b); el.appendChild(who);
var act = document.createElement('div'); act.className = 'act';
act.textContent = adv.level === 'resolution'
? (adv.steer + ' steers - ' + adv.hold + ' holds')
: 'both transmit intent';
el.appendChild(act);
box.appendChild(el);
});
}
function applySnapshot(prox) {
state.positions = prox.positions || [];
state.links = prox.links || [];
state.advisories = prox.advisories || [];
var risk = {};
state.links.forEach(function (l) {
risk[l.a] = Math.max(risk[l.a] || 0, l.risk);
risk[l.b] = Math.max(risk[l.b] || 0, l.risk);
});
state.riskByAgent = risk;
renderAdvisories();
var c = prox.counts || {};
document.getElementById('status').textContent =
(c.agents || 0) + ' agents - ' + (c.advisories || 0) + ' advisories - ' + (c.resolutions || 0) + ' steering';
}
function poll() {
fetch('/api/proximity').then(function (r) { return r.json(); }).then(function (data) {
applySnapshot(data && data.enabled ? data : (data || {}));
}).catch(function () {
document.getElementById('status').textContent = 'offline';
});
}
resize();
poll();
setInterval(poll, 5000);
requestAnimationFrame(draw);
})();
</script>
</body>
</html>`;
}
module.exports = { renderProximityVizHtml };
+20
View File
@@ -8,6 +8,7 @@ const { spawn } = require('child_process');
const { buildControlPaneAction } = require('./actions');
const { buildControlPaneSnapshot, resolveControlPaneConfig } = require('./state');
const { renderControlPaneHtml } = require('./ui');
const { renderProximityVizHtml } = require('./proximity-viz');
const { claimWorkItem, moveWorkItem } = require('./work-item-mutations');
// Run a single write against the local work-item store, then close it. Kept
@@ -265,6 +266,25 @@ function createControlPaneServer(options = {}) {
return;
}
// 3D agent-airspace visualization (Layer 4 observability).
if (req.method === 'GET' && requestUrl.pathname === '/proximity') {
sendText(res, 200, renderProximityVizHtml(), 'text/html; charset=utf-8');
return;
}
if (req.method === 'GET' && requestUrl.pathname === '/api/proximity') {
const snapshot = await buildControlPaneSnapshot({
repoRoot,
dbPath: resolvedConfig.dbPath,
stateDbPath: resolvedConfig.stateDbPath,
config: resolvedConfig,
allowActions,
includeProximity: true
});
sendJson(res, 200, snapshot.proximity || { enabled: true, advisories: [], positions: [], links: [], counts: {} });
return;
}
const actionMatch = requestUrl.pathname.match(/^\/api\/actions\/([^/]+)$/);
if (req.method === 'POST' && actionMatch) {
if (!allowActions) {
@@ -27,6 +27,7 @@ import ipaddress
import socket
import urllib.parse
import urllib.request
from contextlib import contextmanager
from pathlib import Path
from datetime import datetime, timedelta, timezone
from collections import defaultdict
@@ -394,22 +395,36 @@ def detect_project() -> dict:
}
@contextmanager
def _registry_lock():
"""Serialize registry read-modify-write across concurrent sessions.
Acquires the same advisory lock for every registry writer (``_update_registry``
and ``_write_registry``) so ``projects delete/gc/merge`` cannot interleave with
a concurrent observe-time update and corrupt ``projects.json``. No-op on
platforms without ``fcntl`` (Windows).
"""
REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
lock_path = REGISTRY_FILE.parent / f".{REGISTRY_FILE.name}.lock"
lock_fd = None
try:
if _HAS_FCNTL:
lock_fd = open(lock_path, "w")
fcntl.flock(lock_fd, fcntl.LOCK_EX)
yield
finally:
if lock_fd is not None:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
lock_fd.close()
def _update_registry(pid: str, pname: str, proot: str, premote: str) -> None:
"""Update the projects.json registry.
Uses file locking (where available) to prevent concurrent sessions from
overwriting each other's updates.
"""
REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
lock_path = REGISTRY_FILE.parent / f".{REGISTRY_FILE.name}.lock"
lock_fd = None
try:
# Acquire advisory lock to serialize read-modify-write
if _HAS_FCNTL:
lock_fd = open(lock_path, "w")
fcntl.flock(lock_fd, fcntl.LOCK_EX)
with _registry_lock():
try:
with open(REGISTRY_FILE, encoding="utf-8") as f:
registry = json.load(f)
@@ -429,10 +444,6 @@ def _update_registry(pid: str, pname: str, proot: str, premote: str) -> None:
f.flush()
os.fsync(f.fileno())
os.replace(tmp_file, REGISTRY_FILE)
finally:
if lock_fd is not None:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
lock_fd.close()
def load_registry() -> dict:
@@ -445,15 +456,19 @@ def load_registry() -> dict:
def _write_registry(registry: dict) -> None:
"""Write the project registry atomically."""
REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp_file = REGISTRY_FILE.parent / f".{REGISTRY_FILE.name}.tmp.{os.getpid()}"
with open(tmp_file, "w", encoding="utf-8") as f:
json.dump(registry, f, indent=2)
f.write("\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp_file, REGISTRY_FILE)
"""Write the project registry atomically.
Holds the same advisory lock as ``_update_registry`` so concurrent
``projects delete/gc/merge`` and observe-time updates cannot corrupt the file.
"""
with _registry_lock():
tmp_file = REGISTRY_FILE.parent / f".{REGISTRY_FILE.name}.tmp.{os.getpid()}"
with open(tmp_file, "w", encoding="utf-8") as f:
json.dump(registry, f, indent=2)
f.write("\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp_file, REGISTRY_FILE)
def _validate_project_id(project_id: str) -> bool:
@@ -573,7 +588,14 @@ def _project_counts(project_id: str) -> dict:
def _remove_project_storage(project_id: str) -> None:
project_dir = PROJECTS_DIR / project_id
# Defense-in-depth: resolve and confirm the target is contained within
# PROJECTS_DIR before recursively deleting, even though callers validate the
# project id. A relaxed validator or a future caller must never be able to
# turn this into an arbitrary-directory delete.
projects_root = PROJECTS_DIR.resolve()
project_dir = (PROJECTS_DIR / project_id).resolve()
if project_dir == projects_root or projects_root not in project_dir.parents:
raise ValueError(f"refusing to remove {project_dir}: escapes {projects_root}")
if project_dir.exists():
shutil.rmtree(project_dir)
@@ -20,7 +20,13 @@ if [ ! -d "$OLD" ]; then
fi
if command -v pgrep >/dev/null 2>&1; then
if pgrep -f "${HOME}.*observer-loop\\.sh" >/dev/null 2>&1; then
# pgrep -f treats its argument as an extended regular expression, so $HOME
# must be escaped before interpolation. Without this, regex metacharacters in
# the path (e.g. /home/user.name, /home/c++dev, /home/user (work)) would make
# the match over-broad or invalid, causing false negatives (observer missed,
# migration proceeds unsafely) or false positives (migration blocked).
escaped_home="$(printf '%s' "$HOME" | sed 's/[]\.[(){}+*?|^$]/\\&/g')"
if pgrep -f "${escaped_home}.*observer-loop\\.sh" >/dev/null 2>&1; then
echo "Refusing to migrate: observer-loop.sh is running." >&2
echo "Exit all Claude Code sessions, then re-run." >&2
exit 1
@@ -46,6 +46,8 @@ load_registry = _mod.load_registry
_validate_instinct_id = _mod._validate_instinct_id
_validate_import_url = _mod._validate_import_url
_update_registry = _mod._update_registry
_write_registry = _mod._write_registry
_remove_project_storage = _mod._remove_project_storage
_confidence_bar = _mod._confidence_bar
@@ -1043,3 +1045,41 @@ def test_update_registry_atomic_replaces_file(patch_globals):
assert "abc123" in data
leftovers = list(tree["registry_file"].parent.glob(".projects.json.tmp.*"))
assert leftovers == []
def test_write_registry_atomic_no_tmp_leftovers(patch_globals):
# Issue #2294: _write_registry now holds the registry lock like
# _update_registry. It must still write atomically with no stray tmp files.
tree = patch_globals
_write_registry({"keep": {"name": "demo", "root": "/repo", "remote": ""}})
data = json.loads(tree["registry_file"].read_text())
assert data == {"keep": {"name": "demo", "root": "/repo", "remote": ""}}
leftovers = list(tree["registry_file"].parent.glob(".projects.json.tmp.*"))
assert leftovers == []
def test_remove_project_storage_deletes_contained_dir(patch_globals):
tree = patch_globals
target = tree["projects_dir"] / "proj-1"
(target / "instincts").mkdir(parents=True)
(target / "instincts" / "x.md").write_text("hi", encoding="utf-8")
_remove_project_storage("proj-1")
assert not target.exists()
def test_remove_project_storage_missing_dir_is_noop(patch_globals):
# No raise when the contained dir simply does not exist.
_remove_project_storage("never-created")
def test_remove_project_storage_blocks_traversal(patch_globals):
# Issue #2297: defense-in-depth — a traversal id must be refused even when a
# caller skips _validate_project_id, so this can never delete outside
# PROJECTS_DIR.
with pytest.raises(ValueError):
_remove_project_storage("../../etc")
def test_remove_project_storage_blocks_root_itself(patch_globals):
with pytest.raises(ValueError):
_remove_project_storage(".")
@@ -0,0 +1,141 @@
/**
* Regression test for migrate-homunculus.sh $HOME escaping (#2301).
*
* The running-observer guard in migrate-homunculus.sh builds a `pgrep -f`
* pattern from $HOME. `pgrep -f` treats its argument as an extended regular
* expression, so an unescaped $HOME containing regex metacharacters (e.g.
* /home/user.name, /home/c++dev, /home/user (work)) made the match over-broad
* or invalid. That caused either a false negative (a live observer-loop.sh is
* missed and the migration proceeds unsafely) or a false positive (an unrelated
* process matches and the migration is blocked).
*
* The fix escapes the ERE metacharacters in $HOME before interpolation. This
* test pins that behavior by extracting the exact `sed` escaping command from
* the script (so it tests the real implementation, not a copy), then asserting
* that, for HOME values containing metacharacters:
* (a) the escaped pattern matches the literal home path, and
* (b) the escaped pattern does NOT over-match a decoy path that the
* unescaped (regex-expanded) form would have matched.
*
* Run with: node tests/hooks/migrate-homunculus-home-escape.test.js
*/
'use strict';
// migrate-homunculus.sh and this test's assertions rely on POSIX bash, sed, and
// grep -E semantics. Skip on Windows, matching the repo convention for
// bash-dependent clv2 tests (see tests/hooks/observe-subdirectory-detection.test.js).
if (process.platform === 'win32') {
console.log('Skipping bash-dependent migrate-homunculus tests on Windows');
process.exit(0);
}
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const repoRoot = path.resolve(__dirname, '..', '..');
const scriptPath = path.join(
repoRoot,
'skills',
'continuous-learning-v2',
'scripts',
'migrate-homunculus.sh'
);
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed++;
} catch (err) {
console.log(`${name}`);
console.log(` Error: ${err.message}`);
failed++;
}
}
const scriptSource = fs.readFileSync(scriptPath, 'utf8');
// Extract the exact sed escaping command from the script so this test verifies
// the real implementation. Expected form:
// escaped_home="$(printf '%s' "$HOME" | sed 's/.../\\&/g')"
const sedMatch = scriptSource.match(
/escaped_home="\$\(printf '%s' "\$HOME" \| (sed '[^']*')\)"/
);
// Build the pgrep pattern exactly as the script does: ${escaped_home} followed
// by the literal observer-loop.sh regex tail.
function buildPattern(home) {
assert.ok(
sedMatch,
'could not locate the escaped_home sed command in migrate-homunculus.sh; ' +
'the fix for #2301 must escape $HOME before pgrep -f'
);
const sedCmd = sedMatch[1];
const res = spawnSync(
'bash',
['-c', `printf '%s' "$1" | ${sedCmd}`, 'bash', home],
{ encoding: 'utf8' }
);
assert.strictEqual(
res.status,
0,
`sed escaping failed for HOME=${home}: ${res.stderr}`
);
return `${res.stdout}.*observer-loop\\.sh`;
}
// grep -E uses the same ERE engine as pgrep -f. Return true if cmdline matches.
function ereMatches(pattern, cmdline) {
const res = spawnSync('grep', ['-E', pattern], {
input: cmdline,
encoding: 'utf8',
});
return res.status === 0;
}
console.log('\n=== migrate-homunculus.sh $HOME escaping (#2301) ===\n');
test('the running-observer guard no longer interpolates $HOME unescaped', () => {
assert.ok(
!/pgrep -f "\$\{HOME\}/.test(scriptSource),
'pgrep -f must not use ${HOME} directly; it must use the escaped value'
);
assert.ok(
/pgrep -f "\$\{escaped_home\}/.test(scriptSource),
'pgrep -f must use the escaped_home value built from $HOME'
);
});
const problemHomes = ['/home/user.name', '/home/c++dev', '/home/user (work)', '/tmp/h[x]'];
for (const home of problemHomes) {
test(`escaped pattern matches the literal home ${home}`, () => {
const pattern = buildPattern(home);
const cmdline = `/bin/bash ${home}/.local/share/ecc-homunculus/observer-loop.sh`;
assert.ok(
ereMatches(pattern, cmdline),
`expected escaped pattern to match the literal observer cmdline for HOME=${home}`
);
});
}
test('escaped "." does not over-match a different path (#2301 false positive)', () => {
const pattern = buildPattern('/home/user.name');
// The unescaped form ("." as any-char) would match /home/userXname; the
// escaped form must not.
const decoy = '/bin/bash /home/userXname/observer-loop.sh';
assert.ok(
!ereMatches(pattern, decoy),
'escaped pattern must not over-match /home/userXname when HOME=/home/user.name'
);
});
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+43
View File
@@ -226,6 +226,49 @@ async function runTests() {
passed++;
else failed++;
if (
await test('serves the 3D agent-airspace page and the proximity JSON feed', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-proximity-'));
const dbPath = path.join(tempDir, 'ecc2.db');
try {
await writeMinimalDatabase(dbPath);
const app = await createControlPaneServer({
host: '127.0.0.1',
port: 0,
dbPath,
repoRoot: REPO_ROOT,
allowActions: false
});
await app.listen();
try {
// The Enterprise/Pro 3D observability view: a self-contained HTML page.
const page = await fetchLocal(`${app.url}/proximity`);
assert.strictEqual(page.status, 200);
assert.ok((page.headers.get('content-type') || '').includes('text/html'));
const html = await page.text();
assert.ok(html.includes('Agent Airspace'), 'page is titled Agent Airspace');
assert.ok(html.includes('<canvas'), 'page renders a canvas');
assert.ok(html.includes('/api/proximity'), 'page polls the proximity feed');
// The feed the page polls: shape must carry the airspace arrays.
const prox = await fetchLocal(`${app.url}/api/proximity`).then(r => r.json());
assert.ok(Array.isArray(prox.positions), 'positions array present');
assert.ok(Array.isArray(prox.links), 'links array present');
assert.ok(Array.isArray(prox.advisories), 'advisories array present');
assert.ok(prox.counts && typeof prox.counts === 'object', 'counts present');
} finally {
await app.close();
}
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})
)
passed++;
else failed++;
if (
await test('serves health, asset, not-found, invalid body, and read-only action responses', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-routes-'));