Skip to content

fix(store): fail-closed on sign_manifest failure — prevent install-gate bypass - #2050

Merged
jaylfc merged 2 commits into
jaylfc:devfrom
hognek:fix/2027-signing-fail-closed
Jul 28, 2026
Merged

fix(store): fail-closed on sign_manifest failure — prevent install-gate bypass#2050
jaylfc merged 2 commits into
jaylfc:devfrom
hognek:fix/2027-signing-fail-closed

Conversation

@hognek

@hognek hognek commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Fix Kilo WARNING from PR #2027: When sign_manifest raises, the exception was logged but no signature was stored. _verify_manifest_for_install saw None and short-circuited to (True, None) — allowing install with zero tamper protection for any manifest whose signing threw. An attacker inducing a signing failure for a target manifest could bypass the Ed25519 gate silently.

Changes

  • registry.py: Added _signing_failures: set[str] tracking + had_signing_failure() method. When sign_manifest raises during catalog load, the app_id is recorded in the failure set.
  • store_install.py: In _verify_manifest_for_install, when stored_sig is None, check had_signing_failure() first and return (False, msg) if signing failed — block the install gate rather than silently allowing it.

Tests

  • 30/30 registry + signing tests pass
  • No new test regressions in store_install suite

Reference

Kilo: sign_manifest failures silently downgrade to unsigned (PR #2027 review on registry.py:141)

Kanban: t_7f43f306

Summary by CodeRabbit

  • Bug Fixes
    • Improved app installation reliability by running the install-time manifest signature re-check without blocking other activity.
    • Install now fails with an authorization error (and includes an install_id) if the manifest was modified after the initial verification step.
    • Stricter handling of missing or invalid stored signatures to prevent unsafe installs.
    • Made manifest signing/verification output more consistent.
  • Tests
    • Added coverage for install-time manifest tampering and refusal-path scenarios.

@hognek
hognek marked this pull request as ready for review July 19, 2026 13:25
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The install route now performs TOCTOU manifest re-verification in a worker thread, fails closed for missing or invalid manifest data and signatures, and returns structured HTTP 403 errors. New tests cover refusal paths; canonicalization formatting is unchanged semantically.

Changes

Store installation verification

Layer / File(s) Summary
Asynchronous fail-closed re-verification
tinyagentos/routes/store_install.py, tests/test_routes_store_install.py
Moves disk reads and Ed25519 verification into asyncio.to_thread, rejects missing or invalid verification data with HTTP 403, and tests manifest deletion, unreadability, parse failure, missing signatures, and tampering.
Canonicalization formatting
tinyagentos/store_signing.py
Reformats the canonical JSON serialization call without changing its parameters or signing and verification behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant InstallRoute
  participant ManifestFile
  participant AppRegistry
  participant StoreSigning
  InstallRoute->>ManifestFile: read manifest.yaml in worker thread
  ManifestFile-->>InstallRoute: current manifest
  InstallRoute->>AppRegistry: fetch stored signature
  AppRegistry-->>InstallRoute: stored signature
  InstallRoute->>StoreSigning: verify manifest signature
  StoreSigning-->>InstallRoute: verification result
  InstallRoute-->>InstallRoute: complete progress and return 403 on failure
Loading

Possibly related PRs

  • jaylfc/taOS#1924: Related Ed25519 manifest signing and installation verification flow.
  • jaylfc/taOS#2023: Related install-flow signature re-verification behavior.

Suggested reviewers: jaylfc

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main security fix: failing closed to prevent install-gate bypass when signing or verification fails.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gitar-bot

gitar-bot Bot commented Jul 19, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

Comment thread tinyagentos/registry.py
"failed to sign manifest %s — catalog load continues unsigned",
catalog[-1].id,
)
signing_failures.add(catalog[-1].id)

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: Fail-closed signing failure can block valid manifests with date/timestamp fields

yaml.safe_load parses ISO date/timestamp strings (e.g. release_date: 2024-01-01) into datetime.date/datetime.datetime objects. _canonical_manifest_bytes then calls json.dumps(stripped, ...) (store_signing.py:209) with no default=, which raises TypeError on those values. That TypeError is swallowed by this broad except Exception: and the manifest is recorded in signing_failures. Under the new fail-closed policy (had_signing_failure → 403), any legitimate manifest containing a date field becomes permanently uninstallable (until restart), even though it is not tampered.

This turns a latent serialisation limitation into a hard availability regression. Consider making canonicalisation YAML-safe, e.g. json.dumps(stripped, ..., default=str) or re-encoding through yaml.safe_dump, so non-malicious values don't trip the fail-closed path.


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

Comment thread tinyagentos/routes/store_install.py Outdated
disk_path = _manifest_dir / "manifest.yaml"
try:
import yaml as _yaml
on_disk = _yaml.safe_load(disk_path.read_text()) if disk_path.exists() else None

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: Synchronous blocking I/O + crypto inside the async route

This TOCTOU re-read (disk_path.read_text() + yaml.safe_load + Ed25519 verify_manifest_signature) runs directly on the event loop (it is not awaited or offloaded to a thread). For an on-disk catalog this is usually fast, but under load or slow/network filesystems it blocks all concurrent requests for the duration of the disk read and verification.

Consider wrapping the read+verify in await asyncio.to_thread(...) (or reusing a threadpool) to keep the event loop responsive, consistent with how other blocking store operations in this app are handled.


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

@kilo-code-bot

kilo-code-bot Bot commented Jul 19, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 1 Warning, 2 Suggestions | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 2
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/store_signing.py 239 verify_manifest_signature can raise TypeError on date-bearing manifests — json.dumps lacks default=str, but the exception tuple only covers ValueError/InvalidSignature, so TypeError propagates as a 500 instead of failing closed.
tests/test_routes_store_install.py 682 os.chmod(path, 0o000) does not prevent root from reading — the unreadable-path test may pass vacuously in containerised CI. Consider mocking read_text to raise OSError.

SUGGESTION

File Line Issue
tests/test_routes_store_install.py 598 Missing coverage for manifests with yaml.safe_load-produced datetime.date/datetime.datetime values. A test asserting clean failure (403 or signing-failure block) would close the gap left by the canonicalisation change.
Files Reviewed (3 files)
  • tinyagentos/store_signing.py - 1 warning
  • tinyagentos/routes/store_install.py - no new issues
  • tests/test_routes_store_install.py - 1 warning, 1 suggestion

Fix these issues in Kilo Cloud

Previous Review Summaries (3 snapshots, latest commit 471ad2c)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 471ad2c)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (2 files)
  • tinyagentos/routes/store_install.py - wraps TOCTOU re-verify in asyncio.to_thread (fixes previous SUGGESTION)
  • tinyagentos/store_signing.py - adds default=str to json.dumps (fixes previous WARNING)

Previous review (commit e842727)

Status: No Issues Found | Recommendation: Merge

Both issues from the previous review have been resolved in the latest commits:

  • store_signing.py_canonical_manifest_bytes now passes default=str to json.dumps, so manifests containing date/timestamp fields no longer raise TypeError during signing. This removes the fail-closed availability regression that could make legitimate manifests permanently uninstallable (previous WARNING on registry.py:152).
  • store_install.py — the TOCTOU re-read + Ed25519 verification is now wrapped in await asyncio.to_thread(...), keeping the event loop responsive under concurrent load (previous SUGGESTION on store_install.py).

The incremental changes preserve the original gate logic and introduce no new issues.

Files Reviewed (incremental — 2 changed files)
  • tinyagentos/store_signing.py — previous WARNING resolved
  • tinyagentos/routes/store_install.py — previous SUGGESTION resolved

Previous review (commit b451956)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/registry.py 152 Fail-closed signing failure blocks valid manifests with date/timestamp fields — yaml.safe_load produces datetime/date values that json.dumps (store_signing.py:209, no default=) raises on; the broad except Exception records them as signing_failures, so had_signing_failure returns 403 and the manifest becomes permanently uninstallable.

SUGGESTION

File Line Issue
tinyagentos/routes/store_install.py 796 TOCTOU re-read (read_text + yaml.safe_load + Ed25519 verify) runs synchronously on the event loop, blocking all concurrent requests. Consider await asyncio.to_thread(...).
Files Reviewed (7 files)
  • tinyagentos/registry.py - 1 issue
  • tinyagentos/store_signing.py - reviewed (no new issues)
  • tinyagentos/routes/store_install.py - 1 issue
  • tinyagentos/app.py - reviewed (no new issues)
  • tests/test_store_signing.py - reviewed (no new issues)
  • tests/routes/test_store_install_v2.py - reviewed (no new issues)
  • tests/test_routes_store_install.py - reviewed (no new issues)

Fix these issues in Kilo Cloud


Reviewed by step-3.7-flash · Input: 161.4K · Output: 37.4K · Cached: 3.7M

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
tinyagentos/routes/store_install.py (1)

758-776: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Primary signing gate still does blocking disk I/O + Ed25519 verify on the event loop.

The TOCTOU re-verify below (Line 812) is correctly offloaded via asyncio.to_thread, but this primary gate calls _verify_manifest_for_install synchronously, and registry.verify_manifest_signature re-reads manifest.yaml from disk and runs Ed25519 verification inline. For a local on-disk catalog this is fast, but under concurrency or on slow/network filesystems it blocks all requests. Offload it for consistency with the TOCTOU path.

♻️ Offload the primary gate
-    verified, verify_err = _verify_manifest_for_install(
-        manifest_id, registry, _store_pub,
-    )
+    verified, verify_err = await asyncio.to_thread(
+        _verify_manifest_for_install, manifest_id, registry, _store_pub,
+    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/store_install.py` around lines 758 - 776, Offload the
primary manifest verification call in the install route to a worker thread,
matching the existing asynchronous TOCTOU verification path. Update the call to
_verify_manifest_for_install to use asyncio.to_thread while preserving its
arguments and the existing failure response handling.
tests/test_routes_store_install.py (1)

340-357: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stale test names after the 500→422 change. The unknown-backend response is now HTTP 422, but both test method names still say 500.

  • tests/test_routes_store_install.py#L340-L357: rename test_unknown_backend_returns_500test_unknown_backend_returns_422.
  • tests/routes/test_store_install_v2.py#L180-L227: rename test_unknown_backend_returns_500_not_exception → reflect 422.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_routes_store_install.py` around lines 340 - 357, Rename the
unknown-backend test in tests/test_routes_store_install.py:340-357 from
test_unknown_backend_returns_500 to test_unknown_backend_returns_422. Also
rename test_unknown_backend_returns_500_not_exception in
tests/routes/test_store_install_v2.py:180-227 to a name reflecting the 422
response; leave the test assertions and behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tinyagentos/app.py`:
- Around line 1597-1613: Move the store signing keypair initialization and
registry.set_signing_key() call out of create_app() and into the application
lifespan startup path. Ensure create_app() only prepares the relevant state,
while key loading and catalog reload occur lazily during lifespan startup;
update the comments to match the resulting behavior.

---

Nitpick comments:
In `@tests/test_routes_store_install.py`:
- Around line 340-357: Rename the unknown-backend test in
tests/test_routes_store_install.py:340-357 from test_unknown_backend_returns_500
to test_unknown_backend_returns_422. Also rename
test_unknown_backend_returns_500_not_exception in
tests/routes/test_store_install_v2.py:180-227 to a name reflecting the 422
response; leave the test assertions and behavior unchanged.

In `@tinyagentos/routes/store_install.py`:
- Around line 758-776: Offload the primary manifest verification call in the
install route to a worker thread, matching the existing asynchronous TOCTOU
verification path. Update the call to _verify_manifest_for_install to use
asyncio.to_thread while preserving its arguments and the existing failure
response handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 830833b2-14e6-44d5-8f3e-9d58eb7549f2

📥 Commits

Reviewing files that changed from the base of the PR and between 992d797 and e842727.

📒 Files selected for processing (7)
  • tests/routes/test_store_install_v2.py
  • tests/test_routes_store_install.py
  • tests/test_store_signing.py
  • tinyagentos/app.py
  • tinyagentos/registry.py
  • tinyagentos/routes/store_install.py
  • tinyagentos/store_signing.py

Comment thread tinyagentos/app.py Outdated
Comment on lines +1597 to +1613
# Load the store signing keypair lazily here in the lifespan, not in
# create_app(), so a read-only data_dir does not brick startup.
# When the keypair cannot be loaded (missing cryptography, unwritable
# data_dir), signing is simply disabled — the install gate falls
# through to unsigned (fail-open) and the pubkey endpoint returns 404.
_store_pub: bytes | None = None
try:
_store_priv, _store_pub = load_or_create_signing_keypair(data_dir)
if _store_priv is not None:
registry.set_signing_key(_store_priv)
except OSError:
logger.warning(
"store signing keypair could not be created (data_dir=%s may be "
"read-only) — catalog signatures will not be available",
data_dir,
)
app.state.store_signing_pubkey = _store_pub

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the signing-key init is only in the eager create_app body and not duplicated in the lifespan,
# and confirm set_signing_key triggers an eager catalog reload.
rg -nP -C3 'set_signing_key|store_signing_pubkey|load_or_create_signing_keypair' tinyagentos/app.py
rg -nP -C3 'def reload|def _load_catalog|def set_signing_key' tinyagentos/registry.py

Repository: jaylfc/taOS

Length of output: 2722


🏁 Script executed:

#!/bin/bash
sed -n '1500,1620p' tinyagentos/app.py
printf '\n--- registry ---\n'
sed -n '100,175p' tinyagentos/registry.py

Repository: jaylfc/taOS

Length of output: 9774


Move this block into the lifespan, or fix the comments

It still runs in create_app(), so the deferred startup path never takes effect. registry.set_signing_key() also reloads the catalog immediately, making startup pay the manifest walk/parse up front.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/app.py` around lines 1597 - 1613, Move the store signing keypair
initialization and registry.set_signing_key() call out of create_app() and into
the application lifespan startup path. Ensure create_app() only prepares the
relevant state, while key loading and catalog reload occur lazily during
lifespan startup; update the comments to match the resulting behavior.

@jaylfc

jaylfc commented Jul 27, 2026

Copy link
Copy Markdown
Owner

This has gone to CONFLICT against dev and I would like it unblocked ahead of the others, because of what it is: fail-closed on sign_manifest failure preventing an install-gating bypass. A security fix sitting in conflict is the one kind of stale PR that gets worse with time, since every day it is not merged is a day the bypass is live on dev.

Please rebase onto current origin/dev (it moved tonight: #2171, #2172 and #2174 landed). If the conflict is only in .gitignore that will be my #2171/#2173 secret-ignore work, and mine is purely additive so take both sides.

Flagging one thing to watch during the rebase, because it bit #2068 badly tonight: when you resolve, check you are not carrying back anything dev has since changed. A branch cut before a hardening commit can silently revert it during a rebase and the merge will report clean, with no conflict marker to warn you. Worth a git log <merge-base>..origin/dev over the files you touch before you push.

@hognek
hognek force-pushed the fix/2027-signing-fail-closed branch from e842727 to 471ad2c Compare July 27, 2026 23:21

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tinyagentos/routes/store_install.py`:
- Around line 806-819: Update _toctou_reverify to fail closed: return False
whenever manifest.yaml is missing, unreadable, malformed, empty, the signature
lookup fails, or signature verification cannot complete. Wrap the entire on-disk
load, registry.get_signature(manifest_id), and verify_manifest_signature flow in
the existing catch-all handling, while preserving the successful verification
path; ensure the caller still handles the False result so progress.finish() is
reached when the check fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9f64a4e4-15f6-4c91-9993-90ab782a7e84

📥 Commits

Reviewing files that changed from the base of the PR and between e842727 and 471ad2c.

📒 Files selected for processing (2)
  • tinyagentos/routes/store_install.py
  • tinyagentos/store_signing.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tinyagentos/store_signing.py

Comment thread tinyagentos/routes/store_install.py Outdated
…ion + async TOCTOU

- _canonical_manifest_bytes: add default=str to json.dumps so
  yaml.safe_load-produced date/datetime values don't cause signing
  failures on legitimate manifests (Kilo WARNING, registry.py:152)
- TOCTOU re-verify: wrap disk read + Ed25519 verify in
  asyncio.to_thread to avoid blocking the event loop under load
  (Kilo SUGGESTION, store_install.py:796)
- TOCTOU re-verify: fail-closed on read/parse/signature-lookup
  failures — _toctou_reverify now returns False (block install)
  when manifest.yaml is missing, unreadable, malformed, or the
  stored signature cannot be retrieved (CodeRabbit CRITICAL)
@hognek
hognek force-pushed the fix/2027-signing-fail-closed branch from 471ad2c to 2f55923 Compare July 27, 2026 23:35
@jaylfc

jaylfc commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Reviewed properly rather than on the bot verdicts, because two of three say "No Issues Found, Merge" and I think they missed something in store_signing.py.

The core change is right and I want it in. Converting the TOCTOU re-verify from fail-open to fail-closed is exactly correct: previously an unreadable manifest (on_disk = None) or a missing signature (stored_sig is None) silently skipped re-verification and the install proceeded. Now each of those returns False and the install is blocked with 403. Moving it to asyncio.to_thread is also a good call, and I confirmed asyncio is imported at store_install.py:12, so no NameError.

But this PR has no tests, and it is a security fix. Both changed files are production code:

tinyagentos/routes/store_install.py
tinyagentos/store_signing.py

A fail-closed gate that has only ever been observed passing is unproven exactly where it counts. I want a test per refusal branch that proves it goes red: manifest missing, manifest unreadable, safe_load returns empty, stored_sig is None, and signature mismatch. Five cheap tests, each asserting 403. Without them the next refactor can quietly restore fail-open and CI will stay green, which is precisely how this bug got in.

The finding the bots missed: default=str in _canonical_manifest_bytes creates a canonicalisation collision.

store_signing.py:238 now passes default=str to the function that produces the bytes that get signed. I measured what that does:

plain manifest identical:  True          <- good, no compat break
date without default=str:  TypeError: Object of type date is not JSON serializable
date WITH default=str:     {"when": "2026-01-01"}

COLLISION CHECK
  {"when": "2026-01-01"}          -> {"when": "2026-01-01"}
  {"when": date(2026,1,1)}        -> {"when": "2026-01-01"}
  IDENTICAL: True

The good news first: for any manifest that already serialised, the output is byte-identical, so existing signatures are unaffected. default is only consulted for otherwise-unserialisable objects. No compatibility break.

The problem is that a canonical form must be injective, and this one no longer is. yaml.safe_load parses unquoted 2026-01-01 into a datetime.date but quoted "2026-01-01" into a str. Those are two different manifests that now produce the same signing bytes, so a signature valid for one is valid for the other. Type erasure in a signing primitive is the thing canonicalisation exists to prevent, and it is a strange thing to introduce in a PR whose entire point is tightening a signature gate.

Worth noting what it is really fixing: without default=str, a date-bearing manifest raises TypeError inside _verify_sig, which is called outside the try in _toctou_reverify, so it propagates through asyncio.to_thread and surfaces as a 500 rather than a clean 403. That is a genuine bug and worth fixing. But the fix should preserve injectivity.

What I would rather see, in preference order:

  1. Reject non-primitive types explicitly in _canonical_manifest_bytes and return a verification failure (fail-closed, consistent with this PR's thesis), instead of coercing them.
  2. Or, if dates must be supported, type-tag them so a date and its string form cannot collide.
  3. Or, at minimum, move _verify_sig inside the try so a TypeError becomes return False (403) rather than a 500, and drop default=str entirely.

Option 3 is the smallest change and fits this PR best: it makes the unserialisable case fail closed, which is what the PR is for.

Happy to merge once there are refusal-path tests and the canonicalisation question is resolved. The store_install.py half is good work and I do not want it stuck behind this.

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Consolidating what is open on your side so you are not hunting across seven PRs at 1am. You have been pushing steadily (last commit 00:44) and I would rather hand you an ordered queue than a pile.

Ordered by what unblocks the most:

  1. feat(user_shares): add share routes + consent wiring (POST/GET/DELETE /api/shares) #1908doc-gate red, and it gates test(user_shares): add store + route tests with conftest integration #1945. I reproduced it locally: DOC-GATE FAIL: routes -- an API route module was added or removed (edit one of: docs/agent-coordination.md, or add a 'Docs-Reviewed: <why>' trailer). One doc note or one trailer. Cheapest unblock in the queue, and two PRs move.
  2. fix(desktop): add CSRF protection to mutating requests in library, framework-api, memory-api, x-monitor #2165 — rebase (DIRTY since fix(security): install auth guard in all SPA entry points #2174 landed) plus the real bug I just posted: spreads into a literal, and returns a instance, so the CSRF token this PR adds is silently stripped. I measured it. Qodo caught it and I had wrongly dismissed it.
  3. fix(store): fail-closed on sign_manifest failure — prevent install-gate bypass #2050 (this PR) — refusal-path tests, plus the canonicalisation question. and now produce identical signing bytes.
  4. feat(collab): agent delegation handshake + sponsor_contact_id + cascades (D1) #2048 — the one that matters most: make actually skip PIN verification, plus ONE test that mints and successfully redeems. That single test kills A1 and A2 together and is the only thing that will show the feature runs.
  5. feat(library): P3 — heavy tier download, quality preferences, storage accounting, per-source rules #2070 — rebase now that P2 landed as feat(library): P2 rebased — YouTube + Web processors with streaming, content-type gate, timeout guards #2177.
  6. feat(todo): add agent tools for todo lists, remove notes_set_done #2035 — the DB orphan on existing installs, and the real- test.
  7. feat(hub): add X25519 sealed-envelope relay for store-and-forward through taos.my #2034, feat(hub): friend-accept creates contact row and peer-link handshake #2043 — untouched since my reviews; findings stand.

Two things worth knowing that are not asks:

#2177 merged and it was a genuinely good rebase. I verified the silent revert was gone by symbol-diffing against , and your streaming cap fix is correct: client open during the read, content-type gated before the body, cap checked inside . The size-cap test that shrinks to 100 and yields 200 bytes is exactly the right way to test a limit.

#2068 closed as superseded, so nobody builds on the wrong branch.

On #2180: do not add it to required status checks yet. Let it run advisory for a few days first. Against the real #2068 head it produced 85 findings, all technically true but unreadable, and a gate that trains people to waive is worse than none. On a stale branch the answer is always "rebase", not "waive 85 symbols".

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Ignore my previous comment, it posted mangled. I wrote it inside a double-quoted shell string and every backticked identifier got eaten by command substitution, so items 2, 3, 4 and 6 lost the exact technical detail that made them useful. My error and a good argument for heredocs. Corrected queue below.

Ordered by what unblocks the most:

  1. feat(user_shares): add share routes + consent wiring (POST/GET/DELETE /api/shares) #1908doc-gate red, and it gates test(user_shares): add store + route tests with conftest integration #1945. Reproduced locally: DOC-GATE FAIL: routes -- an API route module was added or removed (edit one of: docs/agent-coordination.md, or add a 'Docs-Reviewed: <why>' trailer). One doc note or one trailer. Cheapest unblock here, and two PRs move.

  2. fix(desktop): add CSRF protection to mutating requests in library, framework-api, memory-api, x-monitor #2165 — rebase (DIRTY since fix(security): install auth guard in all SPA entry points #2174 landed) plus a real bug. desktop/src/lib/x-monitor.ts:54:

    const res = await fetch(url, { ...init, headers: { Accept: "application/json", ...init?.headers } });

    withCsrf returns headers as a Headers instance, and spreading a Headers into an object literal yields {}. Measured: {"Accept":"application/json"}, token gone. So the CSRF header this PR exists to add is stripped at that line, and every postJson/putJson in the file inherits it. Fix by normalising: build a new Headers({Accept:...}), then new Headers(init?.headers).forEach((v,k) => headers.set(k,v)). Qodo flagged this and I wrongly dismissed it after checking csrf.ts, which is correct; the bug is at the call site.

  3. fix(store): fail-closed on sign_manifest failure — prevent install-gate bypass #2050 — refusal-path tests, plus the default=str question in _canonical_manifest_bytes. Measured: {"when": "2026-01-01"} (string) and {"when": date(2026,1,1)} produce identical signing bytes, so a signature valid for one is valid for the other. Existing signatures are unaffected, so no compat break, but a canonical form must be injective.

  4. feat(collab): agent delegation handshake + sponsor_contact_id + cascades (D1) #2048 — the one that matters most: make pin_required=False actually skip PIN verification in redeem() (right now it writes a column nothing reads), plus ONE test that mints and successfully redeems. That single test kills A1 and A2 together and is the only thing that will show the feature runs at all.

  5. feat(library): P3 — heavy tier download, quality preferences, storage accounting, per-source rules #2070 — rebase now that P2 landed as feat(library): P2 rebased — YouTube + Web processors with streaming, content-type gate, timeout guards #2177.

  6. feat(todo): add agent tools for todo lists, remove notes_set_done #2035 — the notes_set_done DB orphan on existing installs (seeding is INSERT OR IGNORE every startup, and list_tools does not cross-check SKILL_IMPLEMENTATIONS), plus the registry-miss test using a real AgentRegistryStore rather than MagicMock.

  7. feat(hub): add X25519 sealed-envelope relay for store-and-forward through taos.my #2034, feat(hub): friend-accept creates contact row and peer-link handshake #2043 — untouched since my reviews; findings stand.

Two things that are not asks:

#2177 merged, and it was a genuinely good rebase. I verified the silent revert was gone by symbol-diffing against origin/dev rather than reading the diff, and the streaming fix is correct: client open during the read, content-type gated before the body is consumed, cap checked inside aiter_bytes against a running total. The size-cap test that shrinks _MAX_WEB_BYTES to 100 and yields 200 bytes is exactly the right way to test a limit.

#2068 closed as superseded so nobody builds on the wrong branch.

On #2180: do not add it to required status checks yet. Let it run advisory for a few days. Against the real #2068 head it produced 85 findings, all technically true but unreadable, and a gate that trains people to waive is worse than none. On a stale branch the answer is always "rebase", never "waive 85 symbols individually".

…peError

Drop default=str from _canonical_manifest_bytes — yaml.safe_load
parses unquoted 2026-01-01 into datetime.date but quoted into str,
producing identical signing bytes (canonicalisation collision).

Move _verify_sig inside the try/except in _toctou_reverify so a
TypeError from json.dumps on non-primitive manifest values becomes
return False (403) rather than a 500. Consistent with the PR's
fail-closed thesis.

Add 5 refusal-path tests for the TOCTOU re-verification guard:
manifest missing, manifest unreadable, safe_load returns empty,
stored_sig is None, and signature mismatch — each asserting 403.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
tinyagentos/routes/store_install.py (1)

824-825: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add # noqa: BLE001 to the intentional catch-all.

The blind except Exception is deliberate (fail-closed), but Ruff flags it; other catch-alls in this file (Lines 976, 1043) already carry the suppression.

Suggested tweak
-            except Exception:
+            except Exception:  # noqa: BLE001 - fail closed, never allow on error
                 return False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/store_install.py` around lines 824 - 825, Annotate the
intentional catch-all `except Exception` in the relevant installation flow with
`# noqa: BLE001`, matching the existing suppressions on the other deliberate
catch-alls in this file, while preserving its fail-closed `return False`
behavior.

Source: Linters/SAST tools

tests/test_routes_store_install.py (1)

604-635: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated catalog + signed-registry setup into a fixture/helper.

The same ~25 lines (catalog dir, manifest write, keypair, AppRegistry, app-state wiring) are copied across five new tests; a helper returning (reg, pub, manifest_path) would make each test just its sabotage + assertion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_routes_store_install.py` around lines 604 - 635, Extract the
repeated catalog, manifest, signing-key, AppRegistry, and client app-state setup
from the affected tests into a shared fixture or helper that returns reg, pub,
and manifest_path. Update the five tests to call this helper and retain only
their scenario-specific sabotage and assertions, including the existing
registry/client state initialization behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_routes_store_install.py`:
- Around line 680-691: Guard the permission-sabotage scenario in the test around
manifest_path so it is skipped when running as root, where chmod(0o000) cannot
prevent reading. Preserve the existing chmod cleanup in finally and keep the
non-root assertions for the expected 403 response unchanged.

In `@tinyagentos/routes/store_install.py`:
- Around line 819-821: The unsigned-manifest policy must be consistent across
the initial verification and TOCTOU guard: in
tinyagentos/routes/store_install.py lines 819-821, allow stored_sig to be None
by returning True, matching _verify_manifest_for_install and
AppRegistry.verify_manifest_signature. Update tests/test_routes_store_install.py
lines 736-779 to expect successful handling of unsigned manifests and verify
behavior consistent with that fail-open policy; no tampering error should be
asserted for this case.

---

Nitpick comments:
In `@tests/test_routes_store_install.py`:
- Around line 604-635: Extract the repeated catalog, manifest, signing-key,
AppRegistry, and client app-state setup from the affected tests into a shared
fixture or helper that returns reg, pub, and manifest_path. Update the five
tests to call this helper and retain only their scenario-specific sabotage and
assertions, including the existing registry/client state initialization
behavior.

In `@tinyagentos/routes/store_install.py`:
- Around line 824-825: Annotate the intentional catch-all `except Exception` in
the relevant installation flow with `# noqa: BLE001`, matching the existing
suppressions on the other deliberate catch-alls in this file, while preserving
its fail-closed `return False` behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 84026775-a4ae-484b-846d-22394d80adb2

📥 Commits

Reviewing files that changed from the base of the PR and between 471ad2c and 3204149.

📒 Files selected for processing (3)
  • tests/test_routes_store_install.py
  • tinyagentos/routes/store_install.py
  • tinyagentos/store_signing.py

Comment on lines +680 to +691
# Sabotage: revoke read permission.
try:
os.chmod(manifest_path, 0o000)
resp = await client.post("/api/store/install-v2", json={
"manifest_id": "test-svc",
})
assert resp.status_code == 403
assert resp.json()["error"] == (
"manifest modified between signature verification and install"
)
finally:
os.chmod(manifest_path, 0o644) # restore so tmp_path can clean up

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

chmod 0o000 is a no-op for root — this test fails in root containers.

Many CI images run as root, where the read still succeeds, verification passes, and no 403 is returned. Guard it.

Suggested guard
+    `@pytest.mark.skipif`(
+        hasattr(os, "geteuid") and os.geteuid() == 0,
+        reason="chmod-based permission denial does not apply to root",
+    )
     `@pytest.mark.asyncio`
     async def test_toctou_manifest_unreadable_returns_403(self, client, tmp_path):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Sabotage: revoke read permission.
try:
os.chmod(manifest_path, 0o000)
resp = await client.post("/api/store/install-v2", json={
"manifest_id": "test-svc",
})
assert resp.status_code == 403
assert resp.json()["error"] == (
"manifest modified between signature verification and install"
)
finally:
os.chmod(manifest_path, 0o644) # restore so tmp_path can clean up
`@pytest.mark.skipif`(
hasattr(os, "geteuid") and os.geteuid() == 0,
reason="chmod-based permission denial does not apply to root",
)
`@pytest.mark.asyncio`
async def test_toctou_manifest_unreadable_returns_403(self, client, tmp_path):
# Sabotage: revoke read permission.
try:
os.chmod(manifest_path, 0o000)
resp = await client.post("/api/store/install-v2", json={
"manifest_id": "test-svc",
})
assert resp.status_code == 403
assert resp.json()["error"] == (
"manifest modified between signature verification and install"
)
finally:
os.chmod(manifest_path, 0o644) # restore so tmp_path can clean up
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_routes_store_install.py` around lines 680 - 691, Guard the
permission-sabotage scenario in the test around manifest_path so it is skipped
when running as root, where chmod(0o000) cannot prevent reading. Preserve the
existing chmod cleanup in finally and keep the non-root assertions for the
expected 403 response unchanged.

Comment on lines +819 to +821
stored_sig = registry.get_signature(manifest_id)
if stored_sig is None:
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Unsigned-manifest policy diverges between the first gate and the TOCTOU guard. _verify_manifest_for_install and AppRegistry.verify_manifest_signature both document that a manifest with no stored signature is allowed through, but the TOCTOU guard blocks it with a "manifest modified" message; the new test locks that divergence in.

  • tinyagentos/routes/store_install.py#L819-L821: either return True for stored_sig is None to match the documented fail-open gate, or keep the block and update the docstrings at Lines 220-246 plus the 403 error string so it does not claim tampering.
  • tests/test_routes_store_install.py#L736-L779: update this test to match whichever policy is chosen, and assert an error message specific to the unsigned case rather than the tampering message.
📍 Affects 2 files
  • tinyagentos/routes/store_install.py#L819-L821 (this comment)
  • tests/test_routes_store_install.py#L736-L779
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/store_install.py` around lines 819 - 821, The
unsigned-manifest policy must be consistent across the initial verification and
TOCTOU guard: in tinyagentos/routes/store_install.py lines 819-821, allow
stored_sig to be None by returning True, matching _verify_manifest_for_install
and AppRegistry.verify_manifest_signature. Update
tests/test_routes_store_install.py lines 736-779 to expect successful handling
of unsigned manifests and verify behavior consistent with that fail-open policy;
no tampering error should be asserted for this case.

stripped = {k: v for k, v in manifest_dict.items() if k != SIGNATURE_FIELD}
return json.dumps(stripped, sort_keys=True, ensure_ascii=False).encode("utf-8")
return json.dumps(
stripped, sort_keys=True, ensure_ascii=False,

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_manifest_signature can now raise TypeError on date-bearing manifests — json.dumps lacks default=str, but the exception tuple only covers ValueError/InvalidSignature, so TypeError propagates as a 500 instead of failing closed.


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


# Sabotage: revoke read permission.
try:
os.chmod(manifest_path, 0o000)

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: os.chmod(path, 0o000) does not prevent root from reading — the unreadable-path test may pass vacuously in containerised CI.

Consider replacing the chmod sabotage with a mock that raises OSError/PermissionError on read_text so the failure path is exercised reliably regardless of runtime user.


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

# Restore the original so teardown is clean.
reg.verify_manifest_signature = original_verify # type: ignore[method-assign]

# ── TOCTOU refusal-path tests ──────────────────────────────────────

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: Missing coverage for the non-primitive type case that the canonicalisation changes concern

The 5 new tests cover refusal paths for missing, unreadable, empty, signature-less, and tampered manifests, but none exercise a manifest containing yaml.safe_load-produced datetime.date or datetime.datetime values. Given the explicit canonicalisation collision concern, a test asserting that such manifests fail cleanly (e.g. 403 or signing-failure block) would close the coverage gap.


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

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Both findings are fixed properly. Merging. Verified each against the code rather than the commit message.

1. default=str is gone from _canonical_manifest_bytes, so the canonical form is injective again. A string and a date no longer collide to identical signing bytes.

2. The fail-closed structure is now correct, and you took the option I hoped you would. _verify_sig is inside the try, so an unserialisable manifest returns False and the install is blocked with 403, rather than raising a TypeError through asyncio.to_thread and surfacing as a 500. The whole re-verify path now fails closed on missing, unreadable, empty, unsigned and mismatched.

3. The tests are the right ones, and the comment in them says exactly what I wanted to see: "each test asserts 403 to prove the gate goes red where it counts". test_toctou_manifest_missing_returns_403 and test_toctou_manifest_unreadable_returns_403 pin the refusal branches, which is what makes the fix survive the next refactor.

One thing worth recording, and it is not a criticism of this PR. Every bot comment here is dated 2026-07-19. Your last commit is 2026-07-28T03:14. So the "No Issues Found / Recommendation: Merge" verdicts, and the "Address before merge" one, all reviewed a version of this PR that is nine days old and no longer exists. No bot has looked at the current head.

I am merging on my own review, not on those verdicts, and stating that explicitly so the record is honest. A stale bot approval is worse than no approval, because it reads as a second opinion when it is an opinion about different code. Worth everyone checking comment dates against the head SHA before treating a bot tick as review.

Good fix. This closes a live install-gate bypass on dev.

@jaylfc
jaylfc merged commit 87ac931 into jaylfc:dev Jul 28, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants