Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions scripts/install-hailo.sh
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,9 @@ Environment=HAILO_OLLAMA_PORT=$HAILO_OLLAMA_PORT
# hailo-ollama can leave a bare orphan process listening on the port if it
# crashes during model load (the adopt-an-orphan lesson from PR #1755 for
# rkllama). Reap any such process before (re)start so the bind cannot fail.
ExecStartPre=-/usr/bin/pkill -9 -f 'hailo-ollama'
# Match the exact server invocation, not any command line merely containing
# "hailo-ollama" (which would kill an editor or tail open on these files).
ExecStartPre=-/usr/bin/pkill -9 -f "$HAILO_OLLAMA_BIN serve --port $HAILO_OLLAMA_PORT"
ExecStart=$exec_start
Restart=always
RestartSec=5
Expand Down Expand Up @@ -410,7 +412,13 @@ already_installed() {
# * systemd unit enabled
# * HTTP API responding with a "models" body
[[ -d "$HAILO_OLLAMA_DIR/.git" ]] || return 1
[[ "$(run_as_user git -C "$HAILO_OLLAMA_DIR" rev-parse HEAD 2>/dev/null || true)" == "$HAILO_OLLAMA_REF" ]] || return 1
# Compare resolved commits, not HEAD-vs-ref-name: HAILO_OLLAMA_REF is usually
# a branch/tag ("main"), so comparing it to rev-parse HEAD (a SHA) never
# matched and every re-run needlessly re-fetched and re-installed.
local _head _want
_head="$(run_as_user git -C "$HAILO_OLLAMA_DIR" rev-parse HEAD 2>/dev/null || true)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: _want resolves ${HAILO_OLLAMA_REF}^{commit}. If HAILO_OLLAMA_REF is a branch name that the local clone has never fetched (the dir exists from a prior partial clone), rev-parse returns empty and _want is empty, so [[ -n "$_head" && "$_head" == "$_want" ]] is false and the function returns non-zero → triggers a needless re-fetch/re-install even when HEAD is correct. The default upstream being unreachable (noted in the commit) makes a fresh git fetch likely fail too, so this branch may always re-clone. Consider git fetch before resolving, or tolerating an unresolvable _want when _head is already a valid commit. Low impact since reinstall is idempotent, but worth noting.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

_want="$(run_as_user git -C "$HAILO_OLLAMA_DIR" rev-parse "${HAILO_OLLAMA_REF}^{commit}" 2>/dev/null || true)"
[[ -n "$_head" && "$_head" == "$_want" ]] || return 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: pkill -9 -f "$HAILO_OLLAMA_BIN serve --port $HAILO_OLLAMA_PORT" still matches by substring, not an exact process match.

-f matches the full command line against a regex pattern. The pattern hailo-ollama serve --port 12345 would also match bash -c "... hailo-ollama serve --port 12345 ..." or tail -f /var/log/hailo-ollama serve --port 12345 if such a log path contained the tokens. This is a strict improvement over the old pkill -9 -f 'hailo-ollama' (which matched any command containing hailo-ollama), but it is not a precise match. If HAILO_OLLAMA_BIN contains regex metacharacters they are also interpreted literally-free only by luck. Acceptable for the stated scope; just flagging the residual risk that a coincidental command line containing the exact serve --port N tokens could be killed.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

systemctl is-enabled hailo-ollama.service >/dev/null 2>&1 || return 1
local tags
tags="$(curl -fs "http://localhost:$HAILO_OLLAMA_PORT/api/tags" 2>/dev/null || true)"
Expand Down
13 changes: 12 additions & 1 deletion tests/test_hub_posts.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,15 @@ async def test_posts_append_in_chain_order(self, store, data_dir):

@pytest.mark.asyncio
async def test_verify_chain_passes_for_intact_chain(self, store, data_dir):
author = identity.signing_fingerprint()
pub = identity.public_identity()["signing_pubkey"]
# The chain verifier resolves each entry's key from its declared author,
# so the author -> signing key mapping must be registered.
await store.upsert_author(author, signing_pubkey=pub)
await posts.append_post(store, visibility="public", text="a")
await posts.append_post(store, visibility="circle", text="b")
await posts.append_post(store, visibility="public", text="c")
ok, error = await posts.verify_chain(store, identity.signing_fingerprint())
ok, error = await posts.verify_chain(store, author)
assert ok is True
assert error is None

Expand All @@ -91,6 +96,8 @@ async def test_tampered_post_body_fails_object_verify(self, store, data_dir):
@pytest.mark.asyncio
async def test_tampered_stored_body_fails_chain_verify(self, store, data_dir):
author = identity.signing_fingerprint()
pub = identity.public_identity()["signing_pubkey"]
await store.upsert_author(author, signing_pubkey=pub)
p = await posts.append_post(store, visibility="public", text="real")
await posts.append_post(store, visibility="circle", text="more")

Expand All @@ -112,6 +119,8 @@ async def test_tampered_stored_body_fails_chain_verify(self, store, data_dir):
@pytest.mark.asyncio
async def test_broken_prev_link_fails_chain_verify(self, store, data_dir):
author = identity.signing_fingerprint()
pub = identity.public_identity()["signing_pubkey"]
await store.upsert_author(author, signing_pubkey=pub)
await posts.append_post(store, visibility="public", text="a")
await posts.append_post(store, visibility="circle", text="b")

Expand All @@ -131,6 +140,8 @@ class TestTombstone:
@pytest.mark.asyncio
async def test_delete_drops_content_but_keeps_chain_verifiable(self, store, data_dir):
author = identity.signing_fingerprint()
pub = identity.public_identity()["signing_pubkey"]
await store.upsert_author(author, signing_pubkey=pub)
await posts.append_post(store, visibility="public", text="keep me")
p2 = await posts.append_post(store, visibility="circle", text="delete me")

Expand Down
28 changes: 28 additions & 0 deletions tests/test_hub_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,34 @@ def test_verify_never_raises_on_missing_or_garbage_sig(self, data_dir):
assert hub_store.verify_object({"type": "x"}, pub) is False
assert hub_store.verify_object({"type": "x", "sig": 123}, pub) is False

def test_verify_accepts_object_whose_author_matches_signing_key(self, data_dir):
# A correctly bound object (author == fingerprint of the signing key)
# verifies True.
pub = identity.public_identity()["signing_pubkey"]
signed = hub_store.sign_object(
{"type": "profile", "author": identity.signing_fingerprint(), "version": 1}
)
assert hub_store.verify_object(signed, pub) is True

def test_verify_rejects_object_with_wrong_author(self, data_dir, tmp_path, monkeypatch):
# A valid signature from key A must NOT verify as authored by peer B:
# verify_object binds author to the signing key's fingerprint and rejects
# a mismatched author (the peer-impersonation vector).
signed = hub_store.sign_object(
{"type": "profile", "author": identity.signing_fingerprint(), "version": 1}
)
# Capture the original signing pubkey before switching identities.
orig_pub = identity.public_identity()["signing_pubkey"]
# Mint a different identity in an isolated dir to be the "wrong author".
monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_path / "other"))
other_fp = identity.signing_fingerprint()
# Present the object as authored by the OTHER peer's fingerprint.
impersonated = {**signed, "author": other_fp}
assert hub_store.verify_object(impersonated, orig_pub) is False
# And with a completely foreign (non-fingerprint) author string.
foreign = {**signed, "author": "deadbeef" * 8}
assert hub_store.verify_object(foreign, orig_pub) is False


class TestStoreRoundTrip:
@pytest.mark.asyncio
Expand Down
82 changes: 74 additions & 8 deletions tests/test_routes_account_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,8 @@ async def handler(method, url, **kw):
)

_patch_upstream(monkeypatch, handler)
# Do NOT override the cookie header: the `client` fixture carries the taOS
# controller session that the /api/account/* proxy (rightly) requires, and
# that session cookie is what gets forwarded upstream.
# The `client` fixture carries the taOS controller (local admin) session
# cookie. It must NOT be relayed upstream: the forwarded Cookie is empty.
r = await client.post(
"/api/account/cluster/join/request",
json={"device_name": "Mac", "ttl": "10m"},
Expand All @@ -187,7 +186,8 @@ async def handler(method, url, **kw):
assert r.json()["request_id"] == "r1"
assert captured["url"] == "https://taos.my/api/cluster/join/request"
assert captured["method"] == "POST"
assert captured["cookie"] # the session cookie was passed through
assert "taos_session" not in captured["cookie"]
assert captured["cookie"] == ""


@pytest.mark.asyncio
Expand Down Expand Up @@ -258,7 +258,8 @@ async def handler(method, url, **kw):
assert r.json()["available"] is True
assert captured["url"] == "https://taos.my/api/subdomains/check?name=mybiz"
assert captured["method"] == "GET"
assert captured["cookie"] # session cookie passed through
assert "taos_session" not in captured["cookie"]
assert captured["cookie"] == ""


@pytest.mark.asyncio
Expand All @@ -279,12 +280,16 @@ async def handler(method, url, **kw):
)

_patch_upstream(monkeypatch, handler)
r = await client.post("/api/account/subdomains/claim", json={"name": "mybiz"})
r = await client.post(
"/api/account/subdomains/claim",
json={"name": "mybiz"},
)
assert r.status_code == 200
assert r.json()["name"] == "mybiz"
assert captured["url"] == "https://taos.my/api/subdomains/claim"
assert captured["method"] == "POST"
assert captured["cookie"]
assert "taos_session" not in captured["cookie"]
assert captured["cookie"] == ""
assert "mybiz" in captured["body"]


Expand Down Expand Up @@ -437,7 +442,8 @@ async def handler(method, url, **kw):
assert r.json()["status"] == "registered"
assert captured["url"] == "https://taos.my/api/hub/identity/register"
assert captured["method"] == "POST"
assert captured["cookie"] # session cookie passed through
assert "taos_session" not in captured["cookie"]
assert captured["cookie"] == ""
assert "signing_pubkey" in captured["body"]


Expand Down Expand Up @@ -555,3 +561,63 @@ async def handler(method, url, **kw):
)
assert r.status_code == 503
assert "unreachable" in r.json().get("error", "")


@pytest.mark.asyncio
async def test_forward_to_strips_local_session_cookie(client, monkeypatch):
"""The proxy must NOT relay the local ``taos_session`` admin cookie upstream:
a taos.my log leak would otherwise expose valid local admin session tokens.
An upstream (taos.my) cookie in the same Cookie header IS forwarded."""
monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my")
captured: dict[str, str] = {}

async def handler(method, url, **kw):
captured["cookie"] = kw.get("headers", {}).get("Cookie", "")
return _FakeResp(
content=b'{"username":"alice","status":"registered"}',
headers={"content-type": "application/json"},
)

# Build a Cookie header with the (valid) local admin session plus an upstream
# taos.my session cookie. The proxy receives this from the browser. We keep
# the real session token so the request is authenticated; the fix must strip
# it from what is relayed upstream while forwarding the upstream cookie.
session = client.cookies.get("taos_session", "")
cookie_header = "taos_session=" + session + "; taosgo_session=upstream-value"
_patch_upstream(monkeypatch, handler)
r = await client.post(
"/api/account/hub/identity/register",
json={"signing_pubkey": "aa", "encryption_pubkey": "bb", "proof": "cc"},
headers={"cookie": cookie_header},
)
assert r.status_code == 200
forwarded = captured.get("cookie", "")
# The local session cookie must never reach the upstream.
assert "taos_session" not in forwarded
# The upstream cookie is forwarded as-is.
assert "taosgo_session=upstream-value" in forwarded


@pytest.mark.asyncio
async def test_forward_to_sends_no_cookie_when_only_local_session(client, monkeypatch):
"""When the only cookie present is the local session cookie, the relayed
request must send no Cookie header at all (not an empty one)."""
monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my")
captured: dict[str, str] = {}

async def handler(method, url, **kw):
captured["cookie"] = kw.get("headers", {}).get("Cookie", "")
return _FakeResp(
content=b'{"username":"alice","status":"registered"}',
headers={"content-type": "application/json"},
)

_patch_upstream(monkeypatch, handler)
r = await client.post(
"/api/account/hub/identity/register",
json={"signing_pubkey": "aa", "encryption_pubkey": "bb", "proof": "cc"},
headers={"cookie": "taos_session=" + client.cookies.get("taos_session", "")},
)
assert r.status_code == 200
assert "taos_session" not in captured.get("cookie", "")
assert captured.get("cookie", "") == ""
3 changes: 2 additions & 1 deletion tests/test_routes_account_proxy_hub_slice3.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ async def handler(method, url, **kw):
assert r.json()["request_id"] == "r1"
assert captured["url"] == "https://taos.my/api/hub/requests"
assert captured["method"] == "POST"
assert captured["cookie"]
assert "taos_session" not in captured["cookie"]
assert captured["cookie"] == ""
assert "peerFP" in captured["body"]


Expand Down
69 changes: 69 additions & 0 deletions tests/test_routes_project_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -1126,3 +1126,72 @@ async def test_human_write_not_rate_limited(self, ctx):
)
assert resp.status_code == 201, resp.text


class TestCanvasGeometryClamping:
@pytest.mark.asyncio
async def test_extreme_coordinates_are_clamped_on_create(self, ctx):
pid = await _new_project(ctx, "geom-clamp")
resp = await ctx.client.post(
f"/api/projects/{pid}/canvas/elements",
json={
"kind": "note",
"x": 1_000_000_000,
"y": -1_000_000_000,
"w": 2_000_000_000,
"h": -500,
},
)
assert resp.status_code == 201, resp.text
el = resp.json()["element"]
assert el["x"] == 100_000
assert el["y"] == -100_000
assert el["w"] == 100_000
assert el["h"] == 0

@pytest.mark.asyncio
async def test_extreme_coordinates_are_clamped_on_update(self, ctx):
pid = await _new_project(ctx, "geom-clamp-patch")
el = await _create_note(ctx.client, pid)
resp = await ctx.client.patch(
f"/api/projects/{pid}/canvas/elements/{el['id']}",
json={"x": 1_000_000_000, "y": -1_000_000_000, "w": 2_000_000_000, "h": -500},
)
assert resp.status_code == 200, resp.text
updated = resp.json()["element"]
assert updated["x"] == 100_000
assert updated["y"] == -100_000
assert updated["w"] == 100_000
assert updated["h"] == 0

@pytest.mark.asyncio
async def test_snapshot_render_dimensions_are_clamped(self, ctx):
from pathlib import Path
from PIL import Image

pid = await _new_project(ctx, "snap-clamp")
await ctx.client.post(
f"/api/projects/{pid}/canvas/elements",
json={
"kind": "note",
"x": 1_000_000,
"y": 1_000_000,
"w": 1_000_000,
"h": 1_000_000,
"payload": {"text": "extreme"},
},
)
resp = await ctx.client.get(f"/api/projects/{pid}/canvas/snapshot.png")
assert resp.status_code == 200, resp.text
project = await ctx.app.state.project_store.get_project(pid)
target = (
Path(ctx.app.state.projects_root)
/ project["slug"]
/ "files"
/ "canvas"
/ "snapshot.png"
)
assert target.exists(), f"snapshot not rendered at {target}"
with Image.open(target) as img:
assert img.width <= 8192
assert img.height <= 8192

13 changes: 10 additions & 3 deletions tinyagentos/hub/posts.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,6 @@ async def verify_chain(store: hub_store.HubStore, author: str) -> tuple[bool, Op
which is exactly why the chain index must persist past deletion.
"""
entries = await store.list_chain_entries(author)
pub = identity.public_identity()["signing_pubkey"]
prev_hash: Optional[str] = None
for entry in entries:
if entry.get("prev_hash") != prev_hash:
Expand All @@ -218,8 +217,16 @@ async def verify_chain(store: hub_store.HubStore, author: str) -> tuple[bool, Op
f"does not link to previous entry {prev_hash!r} (chain broken)",
)
obj = await store.get_object(entry["hash"])
if obj is not None and not hub_store.verify_object(obj, pub):
return False, f"seq {entry['seq']}: signature invalid (tampered)"
if obj is not None:
# Verify each entry against the public key resolved from that entry's
# declared author (its signing fingerprint), not one assumed key. A
# directory/network mishap that pairs an entry with the wrong author
# fails the author-to-key binding in verify_object.
resolved = await store.get_author(entry["author"])
if resolved is None or not resolved.get("signing_pubkey"):
return False, f"seq {entry['seq']}: unknown author {entry['author']!r}"
if not hub_store.verify_object(obj, resolved["signing_pubkey"]):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: verify_chain now requires an author row to exist in hub_authors for every chain entry (via get_author).

Previously the chain was verified with the single node identity (identity.public_identity()), so verification always had a key. Now if any entry's author was never registered in hub_authors (e.g. posts created before author registration existed, posts authored by a peer whose upsert_author call was missed, or a tombstone whose referenced author row was pruned), the whole chain fails verification with unknown author. Until peer ingest lands this is largely the local author, but upsert_author is only called in selected flows (e.g. identity register). Confirm that append_post/delete_post reliably persist the author row, or self-chains will report as broken/tampered after this change.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

return False, f"seq {entry['seq']}: signature invalid (tampered)"
prev_hash = entry["hash"]
return True, None

Expand Down
11 changes: 11 additions & 0 deletions tinyagentos/hub/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,21 @@ def verify_object(obj: dict, signing_pubkey_hex: str) -> bool:
Returns False (never raises) on a missing or malformed signature so callers
verifying arbitrary peer objects degrade cleanly. Tampering with any signed
field changes the canonical bytes and fails the check.

The declared ``author`` MUST be the fingerprint (SHA-256) of the public key
that signed the object, so a valid signature from key A cannot be presented
as authored by peer B. This closes the peer-impersonation gap that opens once
peer ingest lands (today the routes force the local fingerprint, so it is not
yet exploitable). The check is rejected unless ``author`` equals that
fingerprint.
"""
sig = obj.get("sig")
if not isinstance(sig, str) or not sig:
return False
author = obj.get("author")
expected_author = identity.fingerprint(signing_pubkey_hex)
if not isinstance(author, str) or author != expected_author:
return False
return identity.verify_signature(signing_pubkey_hex, canonical_bytes(obj), sig)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: verify_object does not check whether the resolved signing_pubkey_hex itself is a valid hex string before identity.fingerprint/verify_signature parse it.

identity.fingerprint does bytes.fromhex(signing_pubkey_hex) and verify_signature later does the same. A non-hex signing_pubkey_hex raises ValueError inside fingerprint. Although the callers here pass keys from hub_authors, verify_object is a public, documented "never raises" API used for "arbitrary peer objects". A malformed stored signing_pubkey would turn this into an uncaught exception rather than returning False. Wrap the fingerprint computation so a bad key degrades to False like every other malformed-input path in this function.

Suggested change
return identity.verify_signature(signing_pubkey_hex, canonical_bytes(obj), sig)
sig = obj.get("sig")
if not isinstance(sig, str) or not sig:
return False
author = obj.get("author")
try:
expected_author = identity.fingerprint(signing_pubkey_hex)
except (ValueError, TypeError):
return False
if not isinstance(author, str) or author != expected_author:
return False
return identity.verify_signature(signing_pubkey_hex, canonical_bytes(obj), sig)

Reply with @kilocode-bot fix it to have Kilo Code address this issue.



Expand Down
3 changes: 3 additions & 0 deletions tinyagentos/projects/canvas/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
_MIN_WIDTH = 800
_MIN_HEIGHT = 600
_PADDING = 40
_MAX_RENDER_DIM = 8192

_KIND_FILL = {
"note": (255, 240, 140, 255),
Expand Down Expand Up @@ -78,6 +79,8 @@ def render_snapshot_png(*, elements: list[dict], output_path: Path) -> Path:
min_x, min_y, max_x, max_y = _bounds(elements)
width = max(_MIN_WIDTH, int(max_x - min_x) + 2 * _PADDING)
height = max(_MIN_HEIGHT, int(max_y - min_y) + 2 * _PADDING)
width = min(width, _MAX_RENDER_DIM)
height = min(height, _MAX_RENDER_DIM)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Clamping render dimensions to _MAX_RENDER_DIM can discard content when max_x - min_x or max_y - min_y exceeds 8192.

_bounds spans from the minimum coordinate to the maximum, so two far-apart elements (e.g. x=0 and x=1_000_000 after clamping is bounded, but x=-100_000 and x=100_000 = 200_000 span) can exceed 8192, while the clamp only shrinks the canvas to 8192px and the draw.rectangle calls render at x - min_x + _PADDING. Elements far outside the 8192 viewport are still drawn (off-canvas) but anything beyond the right/bottom edge is simply cut off with no warning to the caller. This is acceptable as a DoS guard, but note the snapshot can silently omit elements and may be misleading. Consider logging/skipping out-of-range elements or documenting the truncation behavior.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

img = Image.new("RGB", (width, height), color=(255, 255, 255))
draw = ImageDraw.Draw(img, "RGBA")
try:
Expand Down
Loading
Loading