Skip to content

feat(collab): agent delegation handshake + sponsor_contact_id + cascades (D1) - #2048

Open
hognek wants to merge 22 commits into
jaylfc:devfrom
hognek:feat/collab-d1-agent-delegation
Open

feat(collab): agent delegation handshake + sponsor_contact_id + cascades (D1)#2048
hognek wants to merge 22 commits into
jaylfc:devfrom
hognek:feat/collab-d1-agent-delegation

Conversation

@hognek

@hognek hognek commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Task: t_b72392f4. Fixes #2019.

Summary

Implement cross-user agent delegation (milestone D1 of EPIC #2012).

Changes

  • sponsor_contact_id: New column on agent_registry (schema + migration + store API: list_by_sponsor, set_sponsor)
  • Delegation request envelope: Peer inbox dispatches delegation_request envelopes to delegation_handler
  • Policy gate: Per-project auto_approve_delegation knob (default OFF); manual Decisions card on delegation
  • Sponsored scope tier: Default {a2a_send, a2a_receive, project_tasks, canvas_read, registry_feeds_read}
  • Hard-deny: files_write and decisions_write rejected at scope validation for sponsored agents
  • Decisions integration: collab_delegation_gate kind triggers invite mint on approve; deny routes answer back
  • Revoke cascade: contact/membership revocation → revoke all sponsored identities → unassign board tasks
  • 3-level kill-switch: per-agent (existing), per-contact pause (peer link + tokens), per-instance panic (disable peer routes)
  • Invite metadata: JSON column on project_invites; sponsor_contact_id flows through redeem into agent_registry
  • Project store helpers: is_project_member(), get/set_project_setting()

Files changed

  • tinyagentos/agent_registry_store.py — v5 migration + register param + list_by_sponsor + set_sponsor
  • tinyagentos/delegation_handler.py — NEW: scope validation, envelope dispatch, decisions, cascade, kill-switch
  • tinyagentos/routes/peer.py — delegation_request dispatch in inbox, peer panic check
  • tinyagentos/routes/decisions.py — collab_delegation_gate decision handler
  • tinyagentos/routes/agent_auth_requests.py — sponsor_contact_id param in approve_request_record
  • tinyagentos/routes/project_invites.py — metadata extraction + sponsor_contact_id on redeem
  • tinyagentos/projects/invite_store.py — metadata column + mint() param
  • tinyagentos/projects/project_store.py — is_project_member() + get/set_project_setting()
  • tests/test_collab_d1_delegation.py — NEW: 15 tests (scope validation, envelope parsing, registry methods)

Tests

311 tests pass across: test_collab_d1_delegation, test_agent_registry, test_project_store, test_invite_store, test_contacts_peer, test_auth_middleware, test_auth_requests_store

Summary by CodeRabbit

  • New Features
    • Added cross-user delegation with sponsor-aware agent assignment and project-scoped access.
    • Added automatic or approval-based delegation workflows with scope validation.
    • Added project settings and membership controls.
    • Added invite metadata, sponsor propagation, and PIN-optional redemption with secure invite IDs.
    • Added controls to pause delegation by contact, disable peer activity instance-wide, and restore activity.
  • Bug Fixes
    • Prevented unauthorized scopes and unsafe sponsor reassignment.
    • Added fail-closed revocation and approval handling.
  • Tests
    • Expanded coverage across delegation, invitations, sponsorship, settings, and approval workflows.

@hognek
hognek marked this pull request as ready for review July 19, 2026 12:52
@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

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 45b3dabc-8076-4e25-9c4c-9e9b818c5554

📥 Commits

Reviewing files that changed from the base of the PR and between 93471cc and bb0cd89.

📒 Files selected for processing (12)
  • changelog.d/2048-agent-delegation.md
  • tests/projects/test_invite_store.py
  • tests/test_collab_d1_delegation.py
  • tinyagentos/agent_registry_store.py
  • tinyagentos/delegation_handler.py
  • tinyagentos/projects/invite_store.py
  • tinyagentos/projects/project_store.py
  • tinyagentos/routes/agent_auth_requests.py
  • tinyagentos/routes/decisions.py
  • tinyagentos/routes/peer.py
  • tinyagentos/routes/project_invites.py
  • tinyagentos/routes/projects.py

Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Adds cross-user delegation with scope validation, sponsor-aware registration, invite metadata, automatic or manual approval, PIN-free redemption, cascade revocation, project settings, and peer kill switches.

Changes

Delegation and persistence

Layer / File(s) Summary
Sponsor and invite persistence
tinyagentos/agent_registry_store.py, tinyagentos/projects/invite_store.py, tests/test_collab_d1_delegation.py, tests/projects/test_invite_store.py
Stores sponsor and invite metadata with migrations, sponsor operations, JSON deserialization, PIN-free redemption, and high-entropy invite identifiers.
Project membership, settings, and sponsor propagation
tinyagentos/projects/project_store.py, tinyagentos/routes/projects.py, tinyagentos/routes/agent_auth_requests.py, tinyagentos/routes/project_invites.py, tests/test_collab_d1_delegation.py
Adds membership and settings helpers, project-setting routes, sponsor propagation during approval and redemption, project-bound scopes, and malformed-settings coverage.
Delegation validation and approval
tinyagentos/delegation_handler.py, tinyagentos/routes/peer.py, tinyagentos/routes/decisions.py, tests/test_collab_d1_delegation.py, changelog.d/2048-agent-delegation.md
Validates envelopes and scopes, checks project membership, dispatches delegation requests, and routes automatic or Decisions-card approval.
Sponsor revocation and peer kill switches
tinyagentos/delegation_handler.py, tinyagentos/routes/peer.py, tests/test_collab_d1_delegation.py
Revokes sponsored agents, releases assigned tasks, suspends peer links, disables peer traffic, and validates fail-closed project revocation behavior.

Estimated code review effort: 5 (Critical) | ~90 minutes

Merge Risk: 🟠 High · up to bb0cd

This change adds cross-user delegation, persistent sponsored identities, approval, and revocation controls, but the current implementation can overgrant scopes, leave delegated access outside revocation, strand approved requests, or report a contact pause while access remains active. These security and correctness issues make the PR unsafe to merge until fixed or explicitly accepted by the appropriate owner.

Suggested reviewers: jaylfc

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements the main requirements in [#2019], including delegation envelopes, manual approval wiring, disabled-by-default auto-approval, sponsor metadata, sponsored scope restrictions, revocatio… Ensure complete_delegation_approval delivers the minted invite and approval result to the original requester through the peer channel. Add an end-to-end test for the manual approval delivery path. Also surface sponsor reassignment failures …
Docstring Coverage ⚠️ Warning Docstring coverage is 41.71% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 199 functions across 29 files. (1 skipped… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary changes: collaboration delegation, sponsor_contact_id support, and revocation cascades.
Out of Scope Changes check ✅ Passed The changes are aligned with [#2019]. Invite metadata, project settings, peer routing, approval handling, registry updates, revocation controls, and tests directly support delegation and sponsorship r…
Full details: Linked Issues check

Explanation

The PR implements the main requirements in [#2019], including delegation envelopes, manual approval wiring, disabled-by-default auto-approval, sponsor metadata, sponsored scope restrictions, revocation cascades, task unassignment, and kill switches. The reported unresolved risk that manual approval results may not deliver the invite to the requester can prevent the delegation handshake from completing.

Resolution

Ensure complete_delegation_approval delivers the minted invite and approval result to the original requester through the peer channel. Add an end-to-end test for the manual approval delivery path. Also surface sponsor reassignment failures instead of silently ignoring them if sponsor assignment protection is required for this issue scope.

Full details: Out of Scope Changes check

Explanation

The changes are aligned with [#2019]. Invite metadata, project settings, peer routing, approval handling, registry updates, revocation controls, and tests directly support delegation and sponsorship requirements. No unrelated code changes are evident.

Full details: Docstring Coverage

Explanation

Docstring coverage is 41.71% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 199 functions across 29 files. (1 skipped: 1 unsupported.)

✨ 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

reply = "delegation denied"
elif completed:
reply = "delegation approved - the task has been assigned"
else:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CRITICAL: reply is unbound on the approved path — regression from this PR's edit.

The original code had if not approved: ... elif completed: ... else: ... but this change deleted the if not approved: and elif completed: conditions, collapsing them into a bare else:. Now reply = f"The delegation to {to_agent} was denied." is only assigned in the denied branch, while await _route_answer_to_agent(decision, reply) at line 420 always runs. On any approved delegation this raises UnboundLocalError/NameError, breaking the existing #161 gated-delegation approval flow (not just the new collab gate).

Suggested change
else:
if not approved:
reply = "delegation denied"
elif completed:
reply = "delegation approved - the task has been assigned"
else:
reply = "delegation approved, but assigning the task failed - please retry"

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

Comment thread tinyagentos/delegation_handler.py Outdated
return {"status": "error", "error": "registry not available"}

# Find all active agents sponsored by this contact.
sponsored = await registry.list_by_sponsor(contact_id, status="active")

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: project_id is documented as restricting revocation to a single project, but the actual agent revocation ignores it.

list_by_sponsor(contact_id, status="active") returns every sponsored agent for the contact regardless of project, so set_status(..., "revoked") revokes ALL of the contact's sponsored identities across every project. Only the later task-unassign step honors project_id. The docstring says "When project_id is provided, only revoke tokens bound to that project (membership-revoke)" — the behavior contradicts the contract and causes over-revocation. Filter the sponsorees by project when project_id is set.


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

contact_id = decision_metadata.get("contact_id", "")
agent_slug = decision_metadata.get("agent_slug", "")
display_name = decision_metadata.get("display_name", "")
granted_scopes = decision_metadata.get("granted_scopes", [])

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: complete_delegation_approval mints the invite directly from stored granted_scopes without re-applying validate_delegation_scopes or binding to the authorized project.

The stored granted_scopes come from the original decision metadata. If those ever contain files_write/decisions_write they are minted verbatim — the hard-denylist is only enforced at request time (process_delegation_request), not at approval-completion time. Re-run validate_delegation_scopes(...) here and refuse/ strip any deny-scoped invite, and assert project_id matches the project the decision was created for, so a tampered or legacy decision can't mint an over-privileged invite.


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

Reversible by clearing the flag.
"""
# Set a flag that the peer routes check.
request.app.state._peer_disabled = 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: The per-instance panic flag has no re-enable path.

kill_switch_per_instance sets request.app.state._peer_disabled = True and the docstring claims "Reversible by clearing the flag", but no function clears it (and nothing resets it on restart). Operators have no supported way to resume peer traffic after a panic. Add an explicit resume/clear function or document that a restart is required.


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

Comment thread tinyagentos/delegation_handler.py Outdated
try:
from tinyagentos.projects.a2a import ensure_a2a_channel

if project_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.

SUGGESTION: A2A audit line is only emitted when project_id is set.

The "Collaboration with ... has been revoked" system message is guarded by if project_id:, so a contact-wide revoke (project_id=None) leaves no in-channel audit trail of which agents/tasks were revoked. Consider posting the summary to a default/admin channel or logging it even when project_id is absent.


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: 4 Issues Found | Recommendation: Address before merge

Overview

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

SUGGESTION

File Line Issue
tinyagentos/decisions/decision_store.py 140 key interpolated into JSON path via f-string — caller-controlled; validate identifier shape.
tinyagentos/decisions/decision_store.py 139 find_by_metadata lacks LIMIT — pathological duplicate invite_id forces unbounded result through _row_to_decision.
tinyagentos/routes/peer.py 478 Idempotency keyed on invite_id only — colliding ids across projects would leak the other project's decision_id.
tinyagentos/routes/peer.py 505 Silent except Exception around get_project is inconsistent with sibling logging blocks; hides ProjectStore bugs.
Files Reviewed (5 files, 3 new commits since 7be40ca)
  • tinyagentos/peer.py — 0 new issues (previous L200 prefix robustness and L214 endpoint priority findings both resolved by startswith("hub:") guard + sorted(endpoints, key=_endpoint_sort_key); existing L248 silent-except unchanged)
  • tinyagentos/routes/peer.py — 2 issues (L478 cross-project invite_id collision, L505 silent except)
  • tinyagentos/decisions/decision_store.py — 2 issues (L139 unbounded SELECT, L140 f-string JSON path)
  • tests/test_collab_d1_delegation.py — 0 issues (covers idempotency, owner resolution, hub: validation, priority ordering)
  • tests/test_decision_store.py — 0 issues (covers exact-match and substring-non-match for find_by_metadata)

Fix these issues in Kilo Cloud

Previous Review Summaries (13 snapshots, latest commit 7be40ca)

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

Previous review (commit 7be40ca)

Status: 5 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 4

This is an incremental review against the four new commits since the previous review at f0bc7110. The three previously-flagged CRITICAL/WARNING issues on the delivery path (peer.py:196 double-hub-prefix, decisions.py:775 dead to_username, routes/peer.py:288 no-op stub receiver) are all addressed by these new commits: send_envelope now strips hub:, _deliver_delegation_invite no longer assigns a dead local, and the receiver persists a durable free_text decision with invite_id in metadata. Remaining valid flagged findings (silent except Exception at peer.py:228, scope layering/VALID_SCOPES vocabulary) are unchanged and not duplicated.

Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/routes/peer.py 467 _handle_delegation_status is not idempotent on invite_id — distinct nonces for the same invite_id produce duplicate decisions, polluting the recipient's Decisions feed.

SUGGESTION

File Line Issue
tinyagentos/peer.py 200 removeprefix("hub:") is a silent no-op on non-hub: contact ids; a malformed id flows through to a 403'd envelope.
tinyagentos/peer.py 214 Endpoint priority sort was silently dropped; iteration order is now non-deterministic across queries.
tinyagentos/routes/peer.py 467 Decision row is created without project_id= or user_id=, so the row's project_id column is NULL and the decision is ownerless.
tinyagentos/routes/peer.py 468 from_agent=f"peer:{contact_id}" mis-attributes a system notification as a peer-originated free_text question.
Files Reviewed (4 files)
  • tinyagentos/peer.py — 2 issues (L200 prefix robustness, L214 priority order)
  • tinyagentos/routes/peer.py — 3 issues (L467 idempotency + missing project_id/user_id, L468 from_agent attribution)
  • tinyagentos/routes/decisions.py — 0 new issues (dead to_username removed)
  • tests/test_collab_d1_delegation.py — 0 issues (covers new delivery path and durable landing place)

Fix these issues in Kilo Cloud

Previous review (commit f0bc711)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 2

This is an incremental review against the two new commits since the previous review at bb0cd89. Previously flagged CRITICAL/WARNING issues (peer.py:196 double-hub-prefix, decisions.py:775 dead to_username, agent_registry_store.py:923 TOCTOU) remain unaddressed in the new code and are still valid; they are not duplicated here.

Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/peer.py 225 Silent except Exception: continue hides all per-endpoint delivery errors; no log line records which endpoint failed and the returned err string carries no last-failure context.
tinyagentos/routes/peer.py 288 delegation_status handler is a no-op stub — it only logs and echoes the body, so the receiver side never persists the invite and the agent runner cannot redeem it, defeating the purpose of _deliver_delegation_invite.

SUGGESTION

File Line Issue
tinyagentos/delegation_handler.py 17 Inverted layering: core delegation_handler now imports from routes/agent_auth_requests; a shared scopes module would avoid a route-level refactor silently breaking the envelope validator.
tinyagentos/delegation_handler.py 95 VALID_SCOPES is the human-agent grant vocabulary (broader than the sponsored-scope set) and will drift from SPONSORED_DEFAULT_SCOPES; the error also leaks the full route vocabulary to the remote caller.
Files Reviewed (7 files)
  • tests/test_collab_d1_delegation.py - 0 new issues (re-parent test now passes; atomic-guard test updated to match)
  • tinyagentos/agent_registry_store.py - 0 new issues (TOCTOU re-introduction already flagged at L923)
  • tinyagentos/delegation_handler.py - 2 issues (L17 layering, L95 vocabulary duplication)
  • tinyagentos/peer.py - 1 issue (L225 silent exception)
  • tinyagentos/routes/agent_auth_requests.py - 0 new issues
  • tinyagentos/routes/decisions.py - 0 new issues (delivery wired; L775 dead to_username already flagged)
  • tinyagentos/routes/peer.py - 1 issue (L288 stub receiver)

Fix these issues in Kilo Cloud

Previous review (commit bb0cd89)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (6 files)
  • tests/projects/test_invite_store.py
  • tests/test_collab_d1_delegation.py
  • tinyagentos/agent_registry_store.py
  • tinyagentos/delegation_handler.py
  • tinyagentos/projects/invite_store.py
  • tinyagentos/routes/peer.py

Previous review (commit 46a3fbf)

Status: No Issues Found | Recommendation: Merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 0
Files Reviewed (6 files)
  • .github/workflows/ci.yml - Node version bump 20→22
  • .github/workflows/deleted-symbols-gate.yml - Added edited trigger type
  • .github/workflows/secret-ignores-gate.yml - New workflow
  • .github/workflows/security.yml - Replaced direct pip-audit with check_dependency_audit_ignores.py
  • .github/workflows/store-wiring-gate.yml - New workflow
  • changelog.d/2048-agent-delegation.md - Changelog fragment

Previous review (commit 2d52981)

Status: No Issues Found | Recommendation: Merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 0
Files Reviewed (6 files)
  • .github/workflows/ci.yml - Node version bump 20→22
  • .github/workflows/deleted-symbols-gate.yml - Added edited trigger type
  • .github/workflows/secret-ignores-gate.yml - New workflow
  • .github/workflows/security.yml - Replaced direct pip-audit with check_dependency_audit_ignores.py
  • .github/workflows/store-wiring-gate.yml - New workflow
  • changelog.d/2048-agent-delegation.md - Changelog fragment

Previous review (commit 053581e)

Status: 8 Issues Found | Recommendation: Address before merge

Overview

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

CRITICAL

File Line Issue
tinyagentos/delegation_handler.py 280 Invite minted without metadata breaks D1 sponsorship chain

WARNING

File Line Issue
tinyagentos/delegation_handler.py 42 validate_delegation_scopes is a denylist-only check — the sponsored scope tier allowlist is never enforced
tinyagentos/delegation_handler.py 324 complete_delegation_approval skips project-membership re-validation when project_store is unavailable
tinyagentos/delegation_handler.py 513 _unassign_agent_tasks is a silent no-op — it can never unassign a task
tinyagentos/routes/decisions.py N/A Collab delegation approval/deny is never delivered — _route_answer_to_agent returns early for non-@ senders
tinyagentos/projects/project_store.py 436 set_project_setting has a read-modify-write race condition
tinyagentos/routes/peer.py 264 delegation_request dispatch is placed after the "unrecognised kind" log, causing misleading log entries
tinyagentos/routes/decisions.py 670 _apply_collab_delegation_grant reports all approval failures as "minting the invite failed" even when complete_delegation_approval returns a specific error
Files Reviewed (16 files)
  • .github/workflows/ci.yml
  • .github/workflows/deleted-symbols-gate.yml
  • .github/workflows/secret-ignores-gate.yml
  • .github/workflows/security.yml
  • .github/workflows/store-wiring-gate.yml
  • tests/projects/test_invite_store.py
  • tests/test_collab_d1_delegation.py
  • tinyagentos/agent_registry_store.py
  • tinyagentos/delegation_handler.py
  • tinyagentos/projects/invite_store.py
  • tinyagentos/projects/project_store.py
  • tinyagentos/routes/agent_auth_requests.py
  • tinyagentos/routes/decisions.py
  • tinyagentos/routes/peer.py
  • tinyagentos/routes/project_invites.py
  • tinyagentos/routes/projects.py

Fix these issues in Kilo Cloud

Previous review (commit eb604e5)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

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

CRITICAL

File Line Issue
tinyagentos/delegation_handler.py 280 Invite minted without metadata breaks D1 sponsorship chain

WARNING

File Line Issue
tinyagentos/delegation_handler.py 296 complete_delegation_approval is dead code — no caller exists in the codebase
tinyagentos/delegation_handler.py 359 Invite minted without metadata breaks D1 sponsorship chain
tinyagentos/projects/project_store.py 436 set_project_setting has a read-modify-write race condition
Files Reviewed (2 files)
  • tinyagentos/delegation_handler.py - 3 issues
  • tinyagentos/projects/project_store.py - 1 issue

Fix these issues in Kilo Cloud

Previous review (commit f2e727e)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

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

WARNING

File Line Issue
tinyagentos/delegation_handler.py 42 validate_delegation_scopes is a denylist-only check — the sponsored scope tier allowlist is never enforced, so arbitrary scopes like tools_execute, memory_read, files_read pass through to minted invites
tinyagentos/delegation_handler.py 329 complete_delegation_approval skips project-membership re-validation when project_store is unavailable, allowing approval without verifying the sponsor is still a member
tinyagentos/delegation_handler.py 523 _unassign_agent_tasks is a silent no-op — four independent bugs guarantee a zero return (missing method, wrong status filter, wrong parameter, swallowed errors)
tinyagentos/routes/decisions.py 449 Collab delegation approval/deny is never delivered — _route_answer_to_agent returns early for non-@ senders, so both approve and deny replies are silently dropped
Files Reviewed (2 files)
  • tinyagentos/delegation_handler.py - 3 issues
  • tinyagentos/routes/decisions.py - 1 issue

Fix these issues in Kilo Cloud

Previous review (commit 5503bac)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (3 files)
  • tinyagentos/delegation_handler.py - incremental re-review
  • tinyagentos/projects/project_store.py - incremental re-review
  • tinyagentos/routes/agent_auth_requests.py - incremental re-review

Previous review (commit d0f6628)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (2 files)
  • tinyagentos/delegation_handler.py - incremental re-review
  • tinyagentos/routes/decisions.py - incremental re-review
Previously Reported Issues — Now Resolved

All 3 outstanding findings from the prior incremental review (at cccdaf97) have been addressed in the new commits:

  • delegation_handler.py:329complete_delegation_approval now re-validates project membership at approval time via is_project_member(project_id, contact_id, member_kind="human"), closing the stale/revoked-membership gap. ✅
  • delegation_handler.py:467 — Contact-wide A2A audit branch removed; design now relies on per-project membership-revoke paths. The list_projects_for_member AttributeError gap is gone because the branch no longer exists. ✅
  • decisions.py:476_apply_collab_delegation_grant now routes an error answer back to the remote agent on approval failure (previously left the agent hanging). ✅

Previous review (commit 68baed6)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (2 files)
  • tinyagentos/delegation_handler.py - incremental re-review
  • tinyagentos/routes/decisions.py - incremental re-review
Previously Reported Issues — Now Resolved

All 3 outstanding findings from the prior incremental review (at cccdaf97) have been addressed in the new commits:

  • delegation_handler.py:329complete_delegation_approval now re-validates project membership at approval time via is_project_member(project_id, contact_id, member_kind="human"), closing the stale/revoked-membership gap. ✅
  • delegation_handler.py:467 — Contact-wide A2A audit branch removed; design now relies on per-project membership-revoke paths. The list_projects_for_member AttributeError gap is gone because the branch no longer exists. ✅
  • decisions.py:476_apply_collab_delegation_grant now routes an error answer back to the remote agent on approval failure (previously left the agent hanging). ✅

Previous review (commit cccdaf9)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

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

WARNING

File Line Issue
tinyagentos/delegation_handler.py 333 complete_delegation_approval re-applies the scope denylist but does NOT re-validate contact_id is still a project_id member at approval time (remaining gap from prior finding #2; stale/revoked membership still mints a live invite).
tinyagentos/delegation_handler.py 467 Contact-wide A2A audit path calls list_projects_for_member only inside except AttributeError: memberships = []; the method is not defined in this PR, so the branch silently emits no audit messages.

SUGGESTION

File Line Issue
tinyagentos/routes/decisions.py 474 _apply_collab_delegation_grant except Exception logs but routes no answer back; the remote agent is left permanently hanging on approval failure.
Previously Reported Issues — Now Resolved

The 5 findings from the prior review (at dc2caa1b) have all been addressed in the incremental commits up to cccdaf97:

  • decisions.py:422reply unbound on approved path: restored the if not approved / elif completed / else structure. ✅
  • delegation_handler.py:392cascade_sponsor_revoke now filters sponsored agents by is_project_member(project_id, canonical_id) when project_id is set. ✅
  • delegation_handler.py:318complete_delegation_approval now re-applies validate_delegation_scopes (scope denylist) before minting. Partial: project-membership re-check still missing — see new WARNING above. ✅/⚠️
  • delegation_handler.py:581kill_switch_reenable() added as the documented re-enable path. ✅
  • delegation_handler.py:445 — A2A audit line now also emitted for contact-wide (project_id=None) revokes. Partial: only when list_projects_for_member exists — see new WARNING above. ✅/⚠️
Files Reviewed (2 changed since prior review)
  • tinyagentos/delegation_handler.py - 2 issues (post-review changes)
  • tinyagentos/routes/decisions.py - 1 issue (post-review changes)

Fix these issues in Kilo Cloud

Previous review (commit dc2caa1)

Status: 5 Issues Found | Recommendation: Address before merge

Overview

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

CRITICAL

File Line Issue
tinyagentos/routes/decisions.py 418 reply unbound on approved delegation path — UnboundLocalError regression from the collapsed if/elif/else edit (breaks existing #161 gated-delegation approval).

WARNING

File Line Issue
tinyagentos/delegation_handler.py 378 cascade_sponsor_revoke ignores project_id for agent revocation — over-revokes all sponsored identities across every project, contradicting its docstring.
tinyagentos/delegation_handler.py 318 complete_delegation_approval mints from stored granted_scopes without re-applying the scope denylist or verifying the project — over-privileged invite possible from tampered/legacy decision metadata.

SUGGESTION

File Line Issue
tinyagentos/delegation_handler.py 530 Per-instance panic flag _peer_disabled has no re-enable path despite docstring claiming reversibility.
tinyagentos/delegation_handler.py 413 A2A audit line only emitted when project_id is set; contact-wide revoke leaves no in-channel trail.
Files Reviewed (9 files)
  • tests/test_collab_d1_delegation.py
  • tinyagentos/agent_registry_store.py
  • tinyagentos/delegation_handler.py
  • tinyagentos/projects/invite_store.py
  • tinyagentos/projects/project_store.py
  • tinyagentos/routes/agent_auth_requests.py
  • tinyagentos/routes/decisions.py
  • tinyagentos/routes/peer.py
  • tinyagentos/routes/project_invites.py

Fix these issues in Kilo Cloud


Reviewed by minimax-m3:free · Input: 48.6K · Output: 7.9K · Cached: 575.6K

Comment thread tinyagentos/delegation_handler.py Outdated
# approval. The denylist is the authoritative gate; the stored scopes are
# the human-approved set, but the code must never mint tokens for
# hard-denied scopes regardless.
safe_scopes, re_denied = validate_delegation_scopes(granted_scopes)

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: Scope denylist is re-applied here (good), but project-membership is still NOT re-validated at approval time. This is the remaining half of the earlier complete_delegation_approval finding (#2): process_delegation_request checked is_project_member(contact_id, ..., member_kind="human") at request time, but a Decisions card can be approved much later. If the contact was removed from project_id (or the decision metadata was tampered) between request and approval, this still mints a live project invite for a project the sponsor no longer belongs to. Re-run is_project_member(project_id, contact_id, member_kind="human") here and refuse when it fails.


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

Comment thread tinyagentos/delegation_handler.py Outdated
for proj in (memberships or []):
pid = proj.get("id") or proj.get("project_id")
if not pid:
continue

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: list_projects_for_member is called only inside a bare except AttributeError: memberships = []. This method is not defined anywhere in this PR (the new project_store helpers are is_project_member, get/set_project_setting), so the except AttributeError will trigger on every contact-wide revoke and silently produce no audit messages at all — defeating the entire point of this new branch. Either define list_projects_for_member on ProjectStore or drop the silent fallback so the missing-method bug surfaces loudly instead of hiding a missing audit trail for a security-relevant revocation.


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

Comment thread tinyagentos/routes/decisions.py Outdated
except Exception:
logger.warning(
"collab delegation completion failed for decision %s",
decision.get("id"), exc_info=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: When complete_delegation_approval raises, this except Exception logs but routes no answer back to the agent. The decision answer was already persisted as approve, so the remote contact/agent is left permanently hanging with no notification that sponsor minting failed. Route a failure message (like the else branch at line 1316) inside the except so the caller learns the approval did not complete.


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

@jaylfc

jaylfc commented Jul 27, 2026

Copy link
Copy Markdown
Owner

@hognek Rebase needed - this has conflicts against current dev (GitHub reports mergeable_state: dirty), so it cannot merge as-is even though the required checks are green. Green checks on a conflicted branch test the old base, not the merge result.

Everything else on it looks fine from my side; it is purely staleness. Rebase onto current dev and I will take another look.

@hognek
hognek force-pushed the feat/collab-d1-agent-delegation branch 2 times, most recently from aac8157 to d0f6628 Compare July 27, 2026 20:31

@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: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tinyagentos/routes/peer.py (1)

258-272: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The delegation_request dispatch sits after the "unrecognised kind" log.

Every delegation request emits unrecognised kind — accepted, no dispatch before being correctly dispatched, which will mislead anyone debugging the D1 flow. Move the branch above the log (and drop the redundant body_data re-read; it's already bound at line 250).

♻️ Proposed fix
+    # Dispatch delegation requests to the cross-user collab handler (D1).
+    if kind == "delegation_request":
+        from tinyagentos.delegation_handler import process_delegation_request
+
+        result = await process_delegation_request(
+            request, contact_id=contact_id, envelope_body=body_data,
+        )
+        return {"status": result.get("status", "unknown"), "detail": result}
+
     # Log unrecognised kinds for debugging; they are accepted but not dispatched.
     logger.info(
         "peer_inbox: contact=%s kind=%s nonce=%s (unrecognised kind — accepted, no dispatch)",
         contact_id, kind, envelope.get("nonce", "?"),
     )
 
-    # Dispatch delegation requests to the cross-user collab handler (D1).
-    if kind == "delegation_request":
-        from tinyagentos.delegation_handler import process_delegation_request
-
-        body_data = envelope.get("body", {})
-        result = await process_delegation_request(
-            request, contact_id=contact_id, envelope_body=body_data,
-        )
-        return {"status": result.get("status", "unknown"), "detail": result}
-
     return {"status": "received", "kind": kind, "nonce": envelope.get("nonce")}
🤖 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/peer.py` around lines 258 - 272, Move the
delegation_request branch in the peer inbox handler above the unrecognised-kind
logger so valid delegation requests are not logged as unrecognised. Reuse the
existing body_data binding instead of rereading envelope.get("body", {}), while
preserving the process_delegation_request call and response mapping.
🧹 Nitpick comments (5)
tinyagentos/routes/decisions.py (1)

476-479: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the extraneous f prefix (Ruff F541).

🧹 Proposed fix
-        error_msg = (
-            f"Delegation approval failed: an internal error occurred while "
-            f"setting up the sponsored agent. Please retry or check the logs."
-        )
+        error_msg = (
+            "Delegation approval failed: an internal error occurred while "
+            "setting up the sponsored agent. Please retry or check the logs."
+        )
🤖 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/decisions.py` around lines 476 - 479, Remove the
unnecessary f-string prefix from the `error_msg` assignment in the delegation
approval error path, since the message contains no interpolated expressions and
Ruff F541 flags it.

Source: Linters/SAST tools

tinyagentos/projects/project_store.py (1)

377-396: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Read-modify-write on the settings JSON blob can lose a concurrent key update.

Two concurrent set_project_setting calls for different keys both read the old blob and the later write wins. Acceptable for a low-traffic knob, but worth a single UPDATE using SQLite's json_set if settings become hotter.

🤖 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/projects/project_store.py` around lines 377 - 396, Update
set_project_setting to perform the settings mutation in a single SQLite UPDATE
using json_set, rather than reading and rewriting the entire JSON blob in
Python. Preserve existing settings and support updating the requested key
without allowing concurrent calls for different keys to overwrite one another;
retain the method’s boolean success contract.
tinyagentos/routes/project_invites.py (1)

1124-1131: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicated metadata-parsing block — extract a helper.

The nine-line extraction is identical in both redeem paths (and _row_to_dict already deserialises metadata, so this is purely defensive). A small _sponsor_from_invite(invite) helper keeps the two paths from drifting.

Also applies to: 1224-1231

🤖 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/project_invites.py` around lines 1124 - 1131, Extract the
duplicated sponsor_contact_id metadata parsing from both redeem paths into a
shared _sponsor_from_invite(invite) helper. Preserve the existing defensive
handling for missing, string, invalid, and non-dict metadata, then replace both
inline blocks with calls to the helper.
tests/test_collab_d1_delegation.py (1)

126-300: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the delegation handler paths, not just validators and stores.

All 15 tests exercise pure helpers (validate_delegation_scopes, _validate_delegation_envelope_body) and store CRUD. process_delegation_request, complete_delegation_approval, and cascade_sponsor_revoke are untested — a fake app.state with the invite/decision/registry stores would have caught the invite["id"] return-shape mismatch flagged in tinyagentos/delegation_handler.py.

🤖 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_collab_d1_delegation.py` around lines 126 - 300, Add async tests
covering the delegation handler functions process_delegation_request,
complete_delegation_approval, and cascade_sponsor_revoke, using a fake app.state
configured with invite, decision, and registry stores. Exercise successful and
relevant failure/revocation paths, and assert returned values match the stores’
actual invite identifier shape, including catching any invite["id"] versus
invite["invite_id"] mismatch.
tinyagentos/delegation_handler.py (1)

518-522: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Swallowed unassign failures are invisible.

except Exception: pass hides per-task failures, so count under-reports and a revoked agent can stay assigned with no log trace. Log at warning level (Ruff S110).

🤖 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/delegation_handler.py` around lines 518 - 522, Update the
exception handler around task_store.update_task in the unassignment flow to log
each failure at warning level, including the task identifier and exception
details, instead of silently passing. Preserve the existing count behavior so
count increments only after a successful update, and satisfy Ruff S110.

Source: Linters/SAST tools

🤖 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/delegation_handler.py`:
- Around line 294-298: The two mint call sites incorrectly read the invite
identifier from the top-level result. In tinyagentos/delegation_handler.py lines
294-298 and 378-382, update _auto_approve_delegation and
complete_delegation_approval to return the identifier from
invite["record"]["invite_id"] while preserving the existing approved response
shape.
- Around line 272-286: Update the invite creation calls in the current flow and
complete_delegation_approval to pass pin_required=False as the mint argument,
rather than only storing it in metadata. Keep the existing metadata and other
mint parameters unchanged so sponsored-agent invites persist a disabled PIN
requirement.
- Around line 414-420: Update the project-scoped revoke membership check in the
delegation revoke flow so a non-None project_id with a missing project_store
fails closed instead of skipping filtering. Return an appropriate error or
revoke no identities in that case; preserve the existing membership check for
available project stores and contact-wide behavior when project_id is None.

In `@tinyagentos/projects/project_store.py`:
- Around line 363-375: Update get_project_setting to validate that the project's
settings value is a dictionary before calling .get; return default for lists,
strings, or any other non-dict JSON value, while preserving the existing
behavior for missing settings and valid dictionaries.

In `@tinyagentos/routes/agent_auth_requests.py`:
- Around line 455-458: Update the existing-identity reuse flow around
registry.set_sponsor so sponsorship is only assigned when the identity’s current
sponsor_contact_id is empty; preserve any existing sponsor, including when it
belongs to another contact, and avoid re-parenting it during sponsored
redemption.

---

Outside diff comments:
In `@tinyagentos/routes/peer.py`:
- Around line 258-272: Move the delegation_request branch in the peer inbox
handler above the unrecognised-kind logger so valid delegation requests are not
logged as unrecognised. Reuse the existing body_data binding instead of
rereading envelope.get("body", {}), while preserving the
process_delegation_request call and response mapping.

---

Nitpick comments:
In `@tests/test_collab_d1_delegation.py`:
- Around line 126-300: Add async tests covering the delegation handler functions
process_delegation_request, complete_delegation_approval, and
cascade_sponsor_revoke, using a fake app.state configured with invite, decision,
and registry stores. Exercise successful and relevant failure/revocation paths,
and assert returned values match the stores’ actual invite identifier shape,
including catching any invite["id"] versus invite["invite_id"] mismatch.

In `@tinyagentos/delegation_handler.py`:
- Around line 518-522: Update the exception handler around
task_store.update_task in the unassignment flow to log each failure at warning
level, including the task identifier and exception details, instead of silently
passing. Preserve the existing count behavior so count increments only after a
successful update, and satisfy Ruff S110.

In `@tinyagentos/projects/project_store.py`:
- Around line 377-396: Update set_project_setting to perform the settings
mutation in a single SQLite UPDATE using json_set, rather than reading and
rewriting the entire JSON blob in Python. Preserve existing settings and support
updating the requested key without allowing concurrent calls for different keys
to overwrite one another; retain the method’s boolean success contract.

In `@tinyagentos/routes/decisions.py`:
- Around line 476-479: Remove the unnecessary f-string prefix from the
`error_msg` assignment in the delegation approval error path, since the message
contains no interpolated expressions and Ruff F541 flags it.

In `@tinyagentos/routes/project_invites.py`:
- Around line 1124-1131: Extract the duplicated sponsor_contact_id metadata
parsing from both redeem paths into a shared _sponsor_from_invite(invite)
helper. Preserve the existing defensive handling for missing, string, invalid,
and non-dict metadata, then replace both inline blocks with calls to the helper.
🪄 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: f72dc1f2-8989-4e2b-b7d5-b3ea72fe40c2

📥 Commits

Reviewing files that changed from the base of the PR and between ca1d2fe and d0f6628.

📒 Files selected for processing (9)
  • tests/test_collab_d1_delegation.py
  • tinyagentos/agent_registry_store.py
  • tinyagentos/delegation_handler.py
  • tinyagentos/projects/invite_store.py
  • tinyagentos/projects/project_store.py
  • tinyagentos/routes/agent_auth_requests.py
  • tinyagentos/routes/decisions.py
  • tinyagentos/routes/peer.py
  • tinyagentos/routes/project_invites.py

Comment thread tinyagentos/delegation_handler.py
Comment thread tinyagentos/delegation_handler.py Outdated
Comment thread tinyagentos/projects/project_store.py
@jaylfc

jaylfc commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Deep review done against head d0f6628, not against the bot comments. Verdict: do not merge yet, but not for a security reason. The handshake genuinely fails closed. The problem is that the feature has never run end to end, and two of the controls the title advertises are dead code.

Worth saying first: roughly 6 of 8 kilo findings are stale against head, fixed by 7df11bc5/d0f66282. CodeRabbit's two criticals are live and correct.

A1. CRITICAL: invite["id"] raises KeyError on both mint paths.
delegation_handler.py:296 and :380. ProjectInviteStore.mint returns {"record": {..., "invite_id": ...}, "pin": ...} (projects/invite_store.py:241-263); there is no top-level "id". Your own test uses the right shape at tests/test_collab_d1_delegation.py:300. Both raises sit outside the surrounding try, so the auto-approve path 500s on POST /api/peer/inbox and the human-approve path is swallowed into "an internal error occurred" by routes/decisions.py:465. The invite row is already committed at that point, so the caller sees failure while the DB says success. Fix: invite["record"]["invite_id"].

A2. CRITICAL: the minted invite can never be redeemed, because its PIN is generated and discarded.
mint always generates a PIN and stores only the hash (invite_store.py:188-189), and redeem() always compares against pin_hash (:369-383) with no pin_required branch. Both call sites call mint() without pin_required=False and drop the returned pin, recording "pin_required": False in a metadata blob nothing reads. So a PIN exists that no party knows. Fix: pass pin_required=False and return the PIN so the bundle can carry it.

A3. HIGH: the approval result reaches nobody.
_create_delegation_decision sets from_agent=contact_id (e.g. hub:hogne), but _route_answer_to_agent returns early unless from_agent starts with @ (routes/decisions.py:49-51). Every reply in _apply_collab_delegation_grant is therefore a silent no-op, and there is no peer-channel delivery anywhere in this PR despite the docstrings at delegation_handler.py:262 and routes/decisions.py:437 claiming the bundle is returned over the peer channel. After Approve, the invite id exists only in GET /api/projects/{id}/invites.

B1. HIGH: the sponsored scope tier is not enforced.
SPONSORED_DEFAULT_SCOPES is never used as a gate; its only non-test reference is a logger.info. validate_delegation_scopes is a denylist of two strings, one of which (decisions_write) is not in the scope vocabulary at all. The real vocabulary is VALID_SCOPES in routes/agent_auth_requests.py:45-70. So a remote contact can request tools_execute, memory_read, memory_write, files_read, canvas_write, project_tasks_create; nothing strips them; they flow into the decision card and the minted invite, and the subset check at redeem compares against the invite's own scopes so it is trivially satisfied.

This contradicts the design's own threat model at docs/design/cross-user-collaboration.md:392 ("CANNOT read files/memory") and the per-scope consent requirement at :388. The card offers one Approve button for the whole bundle and does not mark which scopes exceed the tier.

Exploit: a contact sends requested_scopes: ["a2a_send","project_tasks","tools_execute","memory_read","files_read"]; the owner sees one Approve button listing them as ordinary requested scopes; approving mints a sponsored identity with tool execution plus memory and file read.

Fix: intersect with an allowlist rather than subtracting a denylist, and render anything outside the tier as distinct elevated scopes needing their own approval.

B2. HIGH: revocation does not cut off delegated authority, because nothing calls the cascade.
agent_token_auth.py:107-111 does re-check status per request, so set_status(cid, "revoked") would work. But cascade_sponsor_revoke, kill_switch_per_contact, kill_switch_per_instance and kill_switch_reenable have zero call sites outside delegation_handler.py itself. The live membership-revoke route routes/projects.py:353-364 does not call the cascade, and the level-3 panic flag can never be set, so is_peer_disabled is inert. The spec's per-request fail-closed check at :262-266 is also unimplemented; complete_delegation_approval:329 re-checks at approval time and its comment misattributes that to the per-request requirement.

Exploit: after the owner removes a contact from the project and revokes it, the sponsored agent keeps its active registry row and JWT and continues reading board, canvas and A2A indefinitely.

Fix: call cascade_sponsor_revoke from routes/projects.py:364, and add a per-request sponsor check for rows with a non-null sponsor_contact_id.

C. HIGH: the task-unassign cascade is a silent no-op.
_unassign_agent_tasks is written against an API that does not exist. task_store.list_for_assignee is not a method, so it always falls back. The fallback filters status="in_progress", but the vocabulary is open|claimed|closed|cancelled, so it always matches nothing. On contact-wide revoke project_id=None gives WHERE project_id = NULL, also nothing. And update_task(task_id, assignee_id=None, status="ready") has two bugs: update_task has no status parameter, and assignee_id=None is skipped by the if raw is not None filter so it can never unassign. All swallowed by except Exception: pass. Held tasks are tracked by claimed_by anyway. Fix: use release_task(task_id, releaser_id), the store's actual unclaim primitive.

E. Tests run but cover none of the risk. No skip guards, all imports resolve, 15 passed and 184 across the related suites with no regressions. But coverage is confined to pure functions and store CRUD: zero tests for process_delegation_request, _auto_approve_delegation, complete_delegation_approval, the cascade, _unassign_agent_tasks, the kill switches, the peer-inbox dispatch or the decisions handler. That is exactly why A1, A2 and C survived a green suite. One end-to-end test (envelope in, decision, approve, redeemable invite) kills A1 and A2 immediately.

Things I checked that are genuinely fine: peer-inbox auth is solid (contact_id derived from the hashed bearer token, envelope from/to binding, Ed25519 signature against the pinned pubkey, nonce replay rejection, rate limit, size cap). Cross-user targeting is gated by is_project_member(..., member_kind="human") and remove_member is a hard DELETE so a removed member reads as a non-member. sponsor_contact_id is never attacker-controllable on either path. Cascade blast radius is bounded by sponsor_contact_id, and the set_sponsor re-parenting branch CodeRabbit flagged is unreachable because _dedupe_handle guarantees no active duplicate (still worth the one-line guard). Migration v5 is idempotent, PRAGMA-guarded and correctly ordered. Decision shape without user_id/options matches the sibling handlers, so that is house style rather than a defect.

Two unrelated nits: the delegation_request dispatch was appended after the "unrecognised kind, no dispatch" log in routes/peer.py:256-272, so every delegation request writes a false no-dispatch audit line before being dispatched; move it up beside the other dispatches. And the explanatory comment deleted at routes/decisions.py:417-418 is unrelated to this change.

Docs: no CHANGELOG entry, and docs/design/cross-user-collaboration.md is not updated even though the code diverges from it in the three places above. Either the code changes or the spec records the deviation, otherwise the spec overstates what ships. No agent-facing docs for the new delegation_request envelope kind, so no external agent could construct one.

Minimum bar to flip this to fix-forward: items A1, A2 and the end-to-end test are non-negotiable, because without them this is a feature that has never once run. Then A3 delivery, B1 allowlist, B2 cascade call site, and either fix or remove _unassign_agent_tasks. If cascades and the kill switch are meant for D2, drop them from the title and file them, rather than shipping unreferenced code that reads as a working control.

@hognek

hognek commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

CodeRabbit findings resolved (2026-07-27 scan)

All 5 findings addressed in 5503bac:

🔴 CRITICAL: Mint return shape (delegation_handler.py)

  • Both _auto_approve_delegation (L296) and complete_delegation_approval (L380) now read invite["record"]["invite_id"] instead of the nonexistent invite["id"]
  • ProjectInviteStore.mint returns {record: {invite_id:...}, pin:...} — confirmed against invite_store.py:241-263

🟠 MAJOR: pin_required=False not passed to mint

  • pin_required=False is now a keyword argument to mint() at both call sites (L273, L357)
  • Removed "pin_required": False from the metadata dict (mint ignores it there)
  • PIN is no longer generated for sponsored-agent invites

🟠 MAJOR: cascade fails open when project_store absent

  • cascade_sponsor_revoke now returns {"status": "error", "error": "project store not available"} when project_id is set but project_store is None
  • Previously the membership filter was silently skipped, causing over-revocation

🟡 MINOR: set_sponsor re-parents existing identity

  • Guard added: if sponsor_contact_id and not existing_active.get("sponsor_contact_id")
  • An identity that already has a sponsor keeps it; only blank sponsors are filled

🟡 MINOR: get_project_setting non-dict guard

  • Added isinstance(settings, dict) check; returns default for lists/strings/other JSON values

Tests: 20/20 pass (5 new regression tests)

  • TestMintReturnShape::test_mint_returns_record_invite_id_key
  • TestMintReturnShape::test_mint_pin_required_false_persisted
  • TestCascadeSponsorRevokeFailClosed::test_cascade_with_project_id_and_no_store_fails
  • TestProjectStoreSettingsGuard::test_get_setting_non_dict_returns_default
  • TestSetSponsorGuard::test_set_sponsor_skips_when_already_set

@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

🤖 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_collab_d1_delegation.py`:
- Around line 376-384: Update cascade_sponsor_revoke to validate project_store
availability before calling list_by_sponsor or entering the sponsored-agent loop
whenever project_id is set. Add coverage for an empty sponsored-agent list,
asserting it returns an error containing “project store not available” rather
than “revoked,” while preserving existing behavior for available stores.
- Around line 434-437: The test currently checks the guard locally before
calling AgentRegistryStore.set_sponsor(), so it does not exercise the
implementation enforcing sponsor binding. Update the test to use
assign_approved_auth_agent_request() through the approval/reuse path for
delegated-agent sponsorship, or move the no-reparenting enforcement into
set_sponsor() and update direct coverage accordingly; verify an already
sponsored agent cannot be reassigned.
🪄 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: 71ff443f-a859-4919-962c-7848330e838a

📥 Commits

Reviewing files that changed from the base of the PR and between d0f6628 and 5503bac.

📒 Files selected for processing (4)
  • tests/test_collab_d1_delegation.py
  • tinyagentos/delegation_handler.py
  • tinyagentos/projects/project_store.py
  • tinyagentos/routes/agent_auth_requests.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • tinyagentos/routes/agent_auth_requests.py
  • tinyagentos/projects/project_store.py
  • tinyagentos/delegation_handler.py

Comment thread tests/test_collab_d1_delegation.py Outdated
Comment thread tests/test_collab_d1_delegation.py Outdated
@jaylfc

jaylfc commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Re-reviewed at head 5503bacd. The fix-forward's message says "resolve 5 CodeRabbit findings", so it was scoped to the bot's list rather than mine. One of my eight is closed.

Two corrections to my own earlier review first, because I was wrong on both and you should not spend time on them. decisions_write is in the vocabulary (agent_auth_requests.py:74-75), so that sub-claim was incorrect, though the denylist-versus-allowlist core of B1 stands. And CHANGELOG in this repo is release-time only (every touch is a chore(release) commit), so "no CHANGELOG entry" was never a real gap. Withdrawn.

I stopped inferring and ran it. I drove process_delegation_request against a real ProjectInviteStore with auto-approve on. As far as I can tell this is the first time this path has ever executed:

STEP 1 process_delegation_request -> {'status': 'approved', 'invite_id': '850361', ...}
STEP 2 minted invite scopes   -> ['a2a_send','files_read','memory_read','tools_execute','canvas_write','project_tasks']
STEP 2 minted pin_required    -> 0
STEP 2 pin returned to caller -> False
STEP 3 redeem with pin=''     -> InvitePinError: invalid invite id or pin
STEP 3 redeem with pin='0000' -> InvitePinError: invalid invite id or pin

So A1 is genuinely dead, no KeyError. A2 and B1 are alive and now demonstrated rather than argued: the minted invite still cannot be redeemed by anyone, and tools_execute, memory_read and files_read mint straight through.

# Finding Status
A1 invite["id"] KeyError FIXED (but untested, see N1)
A2 Minted invite unredeemable NOT FIXED, and now worse
A3 Approval reaches nobody NOT FIXED (decisions.py:44-49 vs delegation_handler.py:221)
B1 Sponsored scope tier not enforced NOT FIXED (validate_delegation_scopes byte-identical)
B2 Cascade / kill-switch never called NOT FIXED (still zero call sites)
C _unassign_agent_tasks silent no-op NOT FIXED (byte-identical)
E No end-to-end test NOT FIXED
Docs spec drift NOT FIXED

On A2 being worse: adding pin_required=False wrote a 0 into a column that nothing reads. Grep shows zero consumers, frontend included. invite_store.py:188 still generates a PIN unconditionally, redeem() at :369-370 still compares pin_hash with no pin_required branch, and both call sites still discard the returned pin (delegation_handler.py:294-298, :378-382). The record now advertises "no PIN required" while redeem still demands one, so the state is more misleading than before the fix.

On C, the reason it silently does nothing, so it can be fixed properly: task_store.py has no list_for_assignee; the status vocabulary is open|claimed|closed|cancelled (:313, :367) so status="in_progress" matches nothing; update_task (:246-256) takes no status param, so the call raises TypeError into the bare except Exception: pass at delegation_handler.py:523-524; and assignee_id=None is skipped by if raw is not None at task_store.py:269. Four independent reasons, any one of which is enough.

N1, and this is the one I would fix first after A2: three of the five new regression tests cannot fail. I checked out d0f66282's tinyagentos/ under the new test file and only two of five went red. The critical fix has no test that proves it. TestSetSponsorGuard (tests/test_collab_d1_delegation.py:434-437) reimplements the guard in the test body instead of calling approve_request_record, so it passes whether or not the production guard exists. Both TestMintReturnShape tests exercise ProjectInviteStore.mint directly and never touch delegation_handler, so neither could have caught invite["id"]. CodeRabbit flagged the first of these independently.

N2: the policy knob cannot be turned on. set_project_setting has zero call sites and there is no route or UI for auto_approve_delegation, so the _auto_approve_delegation branch that just received the critical fix is unreachable in production.

N3: the new fail-closed guard is inside the loop (delegation_handler.py:416-418), so with zero sponsored agents it never fires and returns {"status": "revoked"}.

On the green checks. 694 tests pass across routes, registry, contacts, decisions and projects, plus 263 at store level, and the D1 file is 20/20. No importorskip, no skips. The tests are real; they just test the wrong things. doc-gate passes because docs/doc-gate.toml has no rule covering delegation_handler.py, routes/peer.py, routes/decisions.py, invite_store.py or agent_registry_store.py, so its green says nothing about the spec drift in docs/design/cross-user-collaboration.md:392, which still claims sponsored agents cannot read files or memory. The probe above disproves that line.

Minimum to flip this to mergeable: fix A2 so pin_required=False actually skips PIN verification in redeem() (or return the PIN in the bundle), and add ONE end-to-end test that mints and then successfully redeems. That single test kills A1 and A2 together and is the only thing that will demonstrate the feature runs at all. After that: A3 delivery, the B1 allowlist, a real B2 call site, and fix-or-delete _unassign_agent_tasks.

If the cascades and kill switch are actually D2 scope, please drop them from the title and file them separately rather than shipping unreferenced code. Unreferenced code that reads as a working safety control is worse than no code, because the next person assumes the kill switch works.

hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 27, 2026
A2 fix (jaylfc#2048): redeem() always validated the PIN hash regardless of
pin_required. When pin_required=False the invite stores 0 but the hash
check still ran, making the invite unredeemable with any pin other than
the random one mint() generated. Now gate the PIN verification on
row['pin_required'] being truthy.

N1 fix: 3 of 5 B1 collab regression tests only tested mint storage and
never exercised the redeem path — they could not detect the A2 bug.
Updated test_mint_collab_invite, test_mint_collab_invite_no_pin_required,
and test_default_kind_is_agent to also verify redeem correctness.

Added test_mint_redeem_pin_not_required_e2e: mints with pin_required=False
and successfully redeems with empty string — kills A1+A2 together.

Store tests: 41/41 pass.
@hognek
hognek force-pushed the feat/collab-d1-agent-delegation branch from 5503bac to 877282a Compare July 27, 2026 23:37
hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 28, 2026
jaylfc#2048)

Fix three regressions introduced by 877282a:
- Restore invite["record"]["invite_id"] (was broken to invite["id"])
- Restore pin_required=False as top-level mint arg (was moved to metadata)
- Restore cascade fail-closed guard (was weakened to skip on None store)

Add set_sponsor guard: never re-parent an already-sponsored identity
(the guard is now in production code, not reimplemented in tests).

Fix get_project_setting: handle non-dict settings (malformed DB row).

Rewrite N1 regression tests through real production paths:
- TestMintReturnShape: exercises process_delegation_request e2e
- TestSetSponsorGuard: tests production set_sponsor guard directly
- Add TestDelegationE2E: mint + redeem e2e (kills A1+A2 together)

31/31 tests pass (D1 + invite_store).
@hognek

hognek commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Fixes pushed to 035e722. Summary of corrections:

A2 (PIN skip): invite_store.py:redeem() already had the if row["pin_required"]: guard from 877282a — this was correct. But 877282a introduced three regressions, now fixed:

  1. invite["id"]invite["record"]["invite_id"] in both _auto_approve_delegation and complete_delegation_approval. 877282a changed the correct invite["record"]["invite_id"] to invite["id"], which would KeyError.

  2. pin_required=False restored as top-level mint arg (not inside metadata dict). 877282a moved it into metadata where it has no effect on the column — the pin_required column defaults to True, so the A2 fix was silently undone.

  3. Cascade fail-closed guard restored. 877282a changed if project_id is not None: if project_store is None: return error to if project_id is not None and project_store is not None:, which silently skips filtering when the store is missing.

Guard added to set_sponsor(): a sponsored identity cannot be re-parented to a different sponsor. Clearing with None is still allowed for revoke cascades.

get_project_setting(): now handles non-dict settings (malformed DB row) instead of AttributeError.

N1 tests rewritten through real production paths:

  • TestMintReturnShape: both tests now exercise process_delegation_request end-to-end (was calling store.mint() directly)
  • TestSetSponsorGuard: tests the production set_sponsor() guard directly (was reimplementing guard in test body)
  • TestCascadeSponsorRevokeFailClosed: unchanged (already tested production path)
  • TestProjectStoreSettingsGuard: unchanged (already tested production path)
  • TestDelegationE2E: NEW end-to-end test: mint through process_delegation_request → verify invite → redeem with empty pin → verify claimed (kills A1+A2 together)

Tests: 22/22 (D1 delegation) + 41/41 (invite_store) + 12/12 (project_store) + 124/124 (agent_registry) = 199 passed.

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Verified all eight fix claims in your 16:06 summary against 035e722 at code level, including running the rewritten tests against the regressed commit (6/6 correctly fail there, pass at head). All eight are true, and the summary's scoping was honest about what it did not claim. This is the reporting standard; the 2182 comment earlier today was the opposite.

State: the minimum-to-mergeable bar defined in my earlier review (A2 pin-skip + mint-to-redeem E2E) is met. Merge happens when a real bot review lands on this head (CodeRabbit re-triggered; its limit had eaten the last one). The A3/B1/B2/C items from the after-that list stay open as immediate follow-ups, with one flag raised in priority: A3 means the only reachable production path mints an invite whose ID is never delivered, so D1 cannot complete cross-instance end to end until A3 lands. Card follows on the board; treat it as next after the current batch.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tinyagentos/projects/invite_store.py (1)

473-481: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Failed JSON parse leaves metadata/scopes as a raw string.

Callers treat these as dict/list (e.g. the invite-redemption path reads metadata["sponsor_contact_id"]), so a malformed row surfaces as an AttributeError rather than a benign default.

🛡️ Proposed fix
-        for field in ("scopes", "metadata"):
-            if field in d and isinstance(d[field], str):
-                try:
-                    d[field] = json.loads(d[field])
-                except (ValueError, TypeError):
-                    pass
+        for field, fallback in (("scopes", []), ("metadata", {})):
+            if field in d and isinstance(d[field], str):
+                try:
+                    d[field] = json.loads(d[field])
+                except (ValueError, TypeError):
+                    d[field] = fallback
🤖 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/projects/invite_store.py` around lines 473 - 481, Update the row
deserialization logic around the metadata and scopes fields so failed JSON
parsing replaces the raw string with the appropriate benign default for each
field: a dictionary for metadata and a list for scopes. Preserve successfully
decoded JSON values and return the normalized row from the existing
deserialization function.
🤖 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.

Outside diff comments:
In `@tinyagentos/projects/invite_store.py`:
- Around line 473-481: Update the row deserialization logic around the metadata
and scopes fields so failed JSON parsing replaces the raw string with the
appropriate benign default for each field: a dictionary for metadata and a list
for scopes. Preserve successfully decoded JSON values and return the normalized
row from the existing deserialization function.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f3d19e2-3e48-44aa-be31-343c0d2d19c2

📥 Commits

Reviewing files that changed from the base of the PR and between 5503bac and 035e722.

📒 Files selected for processing (6)
  • tests/projects/test_invite_store.py
  • tests/test_collab_d1_delegation.py
  • tinyagentos/agent_registry_store.py
  • tinyagentos/delegation_handler.py
  • tinyagentos/projects/invite_store.py
  • tinyagentos/projects/project_store.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • tinyagentos/projects/project_store.py
  • tinyagentos/agent_registry_store.py
  • tinyagentos/delegation_handler.py

@hognek

hognek commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Discharged the delivery-path merge-blocker (head 7be40ca4e). Finding → resolution:

  1. hub:hub: double-prefixsend_envelope now receives the removeprefix("hub:")-stripped to_username (the value decisions.py:774 already computed but dropped), so the envelope addresses hub:<user> and the receiver's envelope['to'] != local_id check passes.
  2. dict-vs-string endpoint shapesend_envelope now reads peer endpoints as URL strings, matching the sole writer establish_peer_link (plain-string list). Resolved in one place, the same split tracked as tsk-cuvpep.
  3. No production peer link + receiver drops itdelegation_status now persists durably (into decision_store with invite_id in metadata, mirroring collab_invite at routes/peer.py:430+) so an agent runner can poll and redeem rather than read a log line.

Regression tests now exercise the delivery path directly (send_envelope / _deliver_delegation_invite): 66 passed, 15 deselected. mergeStateStatus reports BLOCKED (your lead review) / mergeable: MERGEABLE — no merge conflicts.

Comment thread tinyagentos/peer.py Outdated
# and the receiver 403s it.
envelope = build_envelope(
from_username=from_username,
to_username=to_contact_id.removeprefix("hub:"),

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: removeprefix("hub:") silently no-ops on non-hub: contact ids

The new code assumes every to_contact_id is a hub:<user> id. If a legacy or alternate contact id (e.g. peer:abc) is passed, removeprefix is a no-op and the bare id is forwarded to build_envelope, which will then prefix it again and produce an envelope addressed to hub:peer:abc — never matching the remote local_id and silently 403'd by the recipient. Either normalise at the caller (strip the hub: once and document the contract) or raise a clear ValueError here so the caller fixes the wrong shape rather than discovering it via a missing delivery.

Comment thread tinyagentos/peer.py Outdated
outbound_token = peer_link.get("outbound_token", "")
# Endpoints are plain URL strings (the canonical shape written by
# ``establish_peer_link``), not ``{"url": ..., "priority": ...}`` dicts.
for ep in peer_link["endpoints"]:

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: Endpoint priority sort was silently dropped

The previous code sorted endpoints by e.get("priority", 99) so the lowest-priority-number endpoint (the one the contact declared as primary) was always tried first. The replacement iterates peer_link["endpoints"] in dict-iteration order, which under SQLite aiosqlite is row order — non-deterministic across queries and changing the wire shape means a healthy primary endpoint can sit behind a dead one on every retry. Either keep a deterministic order (sort by presence of priority, fall back to insertion order) or document the new ordering semantics explicitly.

}

try:
decision = await decision_store.create(

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: _handle_delegation_status is not idempotent on invite_id

The handler unconditionally calls decision_store.create(...) for every received envelope. Nonces protect against exact replays, but nothing prevents two distinct delegation_status envelopes (different nonces) for the same invite_id — e.g. a retry from the sponsor after a 5xx they treated as failed, or the sender looping the delivery path. The result is two free_text decisions with identical metadata.invite_id and identical questions, polluting the user's Decisions feed and potentially confusing the agent runner polling for "the" delegation decision. Check decision_store.list(metadata_invite_id=invite_id) (or extend the store with a metadata JSON-key lookup) before creating and return the existing decision_id on duplicates.

}

try:
decision = await decision_store.create(

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: Decision row is created without project_id or user_id

body_data["project_id"] is read into metadata but never passed as the project_id= kwarg to decision_store.create, so the row's project_id column is NULL. Any project-scoped query on the recipient side (e.g. an agent runner listing project_id == X decisions) will not surface this delegation notification. Same for user_id — the row is ownerless, so only an admin (or no one, depending on the answer route's auth) can act on it. For consistency with _handle_collab_invite at line 553, either pass project_id=project_id or None to create, or at minimum document why the decision is intentionally project-less.

Comment thread tinyagentos/routes/peer.py Outdated

try:
decision = await decision_store.create(
from_agent=f"peer:{contact_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.

SUGGESTION: from_agent=f"peer:{contact_id}" mis-attributes the notification

The decision is a system-generated notification about a delegation approval, not a request from the peer contact. Tagging it with from_agent="peer:<contact>" makes it look like the remote contact is asking the local user a free_text question. Downstream consumers that filter by from_agent prefix (e.g. surfacing "questions from peers" in the UI) will mis-categorise this. Use a system-prefixed identity (e.g. from_agent="system:delegation") or the local user's identity, and keep the originating contact only in metadata.contact_id.

async with self._db.execute(
"SELECT * FROM decisions "
"WHERE json_extract(metadata, ?) = ? ORDER BY created_at DESC",
(f"$.{key}", value),

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: key is interpolated into the JSON path via f-string — f"$.{key}". The caller controls this string and SQLite JSON1 will reject invalid path syntax with an error rather than allowing injection, but the API surface still trusts the caller to pass a safe metadata key. Validate key (e.g. key.isidentifier() or re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key)) at function entry so future callers cannot break the query or leak schema details via a malformed JSON path.


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

"""
async with self._db.execute(
"SELECT * FROM decisions "
"WHERE json_extract(metadata, ?) = ? ORDER BY created_at DESC",

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: find_by_metadata is documented as the idempotency probe for _handle_delegation_status, but the query has no LIMIT. A pathological store with thousands of rows sharing one invite_id (which the schema does not prevent) would force the whole list through _row_to_decision and json.loads on every duplicate delivery. Add LIMIT 1 and drop ORDER BY created_at DESC — the caller already takes existing[0], and the metadata index is keyed by invite_id so order does not matter for the duplicate-detection semantic.


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

project = await project_store.get_project(project_id)
if project:
user_id = project.get("user_id") or ""
except Exception:

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: The new try/except Exception around project_store.get_project(project_id) silently swallows every error to user_id = "". Inconsistent with the sibling block just above (L472) which logs a warning on find_by_metadata failure, and inconsistent with the new create block below (L522) which logs an error. A ProjectStore bug that makes get_project raise will hide the owner of every delegation-status decision in this inbox. Add logger.warning(...) here for parity.


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

invite_id, exc,
)
existing = []
if existing:

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: Idempotency is keyed on invite_id alone, not (invite_id, project_id). If two sponsors independently chose the same invite_id (or a manual DB edit did), the second delivery would be silently swallowed and returned as a duplicate of the first project's decision — leaking the other project's decision_id in the response and starving the actual recipient. Either tighten the lookup to json_extract(metadata, '$.invite_id') = ? AND json_extract(metadata, '$.project_id') = ?, or document that invite_id is project-scoped.


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

hognek added a commit to hognek/tinyagentos that referenced this pull request Aug 30, 2026
…ork, jaylfc#2048)

Replace the single sponsor_contact_id column on agent_registry with an
agent_sponsorship association keyed by (canonical_id, project_id), so one
agent identity reused by handle across projects can be sponsored
independently by each project's delegating contact.

- v8 migration backfills the association from the legacy sponsor_contact_id
  column (keyed to the empty project_id sentinel), reproducing the old
  single-sponsor cascade for pre-change rows.
- list_by_sponsor resolves through the association.
- set_sponsorship writes INSERT OR IGNORE (atomic first-writer-wins on the
  PRIMARY KEY); remove_sponsorship + list_sponsorships_for_identity /
  list_sponsorships_by_contact added.
- cascade_sponsor_revoke removes the contact's association rows and revokes
  the identity only when no association remains.
- approve_request_record records sponsorship via the association instead of
  a column stamp.
hognek added a commit to hognek/tinyagentos that referenced this pull request Aug 30, 2026
…project) association, jaylfc#2048)

Cover jaylfc's acceptance criteria:
- two contacts / two projects / one handle: A's revoke spares the identity
  under B's sponsorship; B's revoke kills it outright.
- project-scoped revoke spares other projects.
- per-contact kill-switch observable through agent_token_auth._verify_agent_scope
  refusing a live bearer (403), not list_by_sponsor row count.
- concurrency: two writes for the same (identity, project) leave exactly one
  row with a deterministic first-writer-wins winner.
- v8 migration backfill reproduces the old single-sponsor cascade on a
  pre-change row.

Replaces the old TestSetSponsorAtomicGuard 'in (hub:A, hub:B)' assertion.
@hognek

hognek commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Sponsorship rework landed (head a61324c30, on top of the delivery-path fix). This is tsk-mjnfjn — sponsorship becomes a per-(identity, project) association, per d1-per-project-sponsorship.md.

What changed:

  • New agent_sponsorship table (canonical_id, project_id, sponsor_contact_id, created_at, PK on (canonical_id, project_id)).
  • Mint writes the association row for the delegation's project instead of stamping the identity.
  • list_by_sponsor and cascade_sponsor_revoke now key on the association: revoking a contact revokes the association, and only revokes the identity itself when no sponsorship rows remain — so an agent sponsored by two contacts in two projects survives one revoke and dies on the second.
  • Migration v8 backfills the association from the legacy sponsor_contact_id column (retained read-only for one release, then dropped).
  • Concurrency uses INSERT OR IGNORE on the association PK — the atomic first-writer-wins discipline (the original race was real, it just belonged on the association table, not the identity).

Acceptance (all four named cases):

  1. Two contacts / two projects / one handle — revoking hub:A kills A's sponsorship + P1 access while B's P2 survives; revoking hub:B kills the identity.
  2. Kill switch measured via agent_token_auth._verify_agent_scope refusing a live bearer (not list_by_sponsor row count).
  3. Concurrency — deterministic first-writer-wins, single row (the old in (hub:A, hub:B) assertion is gone).
  4. Migration backfill reproduces the old single-sponsor cascade on a pre-change DB.

Tests: test_collab_d1_delegation.py 42 passed, test_agent_registry_store.py 145 passed.

…des (D1)

Implement cross-user agent delegation (milestone D1 of jaylfc#2012):

- Add sponsor_contact_id column to agent_registry (schema + migration + store API)
- Delegation-request envelope dispatch in peer inbox (kind=delegation_request)
- Policy gate with per-project auto_approve_delegation knob (default OFF)
- Sponsored scope tiers: {a2a_send, a2a_receive, project_tasks, canvas_read, registry_feeds_read}
- Hard-deny files_write and decisions_write at scope validation
- Decisions card integration: collab_delegation_gate kind triggers invite mint on approve
- Revoke cascade: contact/membership change → revoke sponsored identities → unassign tasks
- 3-level kill-switch: per-agent (existing), per-contact pause, per-instance panic
- Invite metadata column with sponsor_contact_id propagation through redeem flow
- Project store helpers: is_project_member(), get/set_project_setting()

Fixes: jaylfc#2019
CRITICAL: Restore reply assignment in _apply_delegation_grant (regression
from collapsed if/elif/else)

WARNING: cascade_sponsor_revoke now filters agents by project_id via
project_store.is_project_member for membership-scoped revoke

WARNING: complete_delegation_approval re-applies scope denylist
(validate_delegation_scopes) before minting invite

SUGGESTION: Add kill_switch_reenable() for per-instance panic recovery

SUGGESTION: A2A audit line emitted for contact-wide revoke across all
projects the contact is a member of
WARNING: Re-validate project membership at approval time in
complete_delegation_approval (fail-closed runtime check per design spec 5)

WARNING: Remove fragile list_projects_for_member call (method does not
exist on project_store). Simplify A2A audit to project-scoped only.

SUGGESTION: Route error answer back when complete_delegation_approval
raises, so the remote contact receives feedback on approval failure.
A2 fix (jaylfc#2048): redeem() always validated the PIN hash regardless of
pin_required. When pin_required=False the invite stores 0 but the hash
check still ran, making the invite unredeemable with any pin other than
the random one mint() generated. Now gate the PIN verification on
row['pin_required'] being truthy.

N1 fix: 3 of 5 B1 collab regression tests only tested mint storage and
never exercised the redeem path — they could not detect the A2 bug.
Updated test_mint_collab_invite, test_mint_collab_invite_no_pin_required,
and test_default_kind_is_agent to also verify redeem correctness.

Added test_mint_redeem_pin_not_required_e2e: mints with pin_required=False
and successfully redeems with empty string — kills A1+A2 together.

Store tests: 41/41 pass.
jaylfc#2048)

Fix three regressions introduced by 877282a:
- Restore invite["record"]["invite_id"] (was broken to invite["id"])
- Restore pin_required=False as top-level mint arg (was moved to metadata)
- Restore cascade fail-closed guard (was weakened to skip on None store)

Add set_sponsor guard: never re-parent an already-sponsored identity
(the guard is now in production code, not reimplemented in tests).

Fix get_project_setting: handle non-dict settings (malformed DB row).

Rewrite N1 regression tests through real production paths:
- TestMintReturnShape: exercises process_delegation_request e2e
- TestSetSponsorGuard: tests production set_sponsor guard directly
- Add TestDelegationE2E: mint + redeem e2e (kills A1+A2 together)

31/31 tests pass (D1 + invite_store).
…ites key

- Rebased onto current origin/dev (no conflicts)
- Fixed app.state key mismatch: project_invite_store → project_invites
  (app.py registers as project_invites, handler was looking up the wrong key)
- invite["record"]["invite_id"] already correct in both call sites
CodeRabbit findings (PR jaylfc#2048):
- CRITICAL: Both mint call sites correctly use invite['record']['invite_id'].
- MAJOR: Both _auto_approve_delegation and complete_delegation_approval
  passed invalid metadata={...} dict to mint(). Fix: drop metadata,
  promote display_name to top-level kwarg.
…uto-approve setting (jaylfc#2048)

The manual approval path had no caller: complete_delegation_approval (the
'minted on decisions approve' handler) was never invoked, and the
auto_approve_delegation setting was unexposed (set_project_setting had no
route). Wire _apply_collab_delegation_grant into answer_decision so approving
a collab_delegation_gate decision actually mints the sponsored invite, and add
GET/PUT project setting routes for auto_approve_delegation.
…ment (jaylfc#2048)

The rebase onto dev accidentally reverted five CI workflow files: it deleted
secret-ignores-gate.yml and store-wiring-gate.yml, downgraded node 22 -> 20
in ci.yml, dropped the deleted-symbols-gate "edited" retrigger, and reverted
security.yml's uv/pip-audit hardening. Restore all five to dev's version —
the delegation feature does not touch CI.

Docs-Reviewed: delegation is a new cross-user agent surface. The D1 contract
is specified in the PR body and pinned by 15 tests in
tests/test_collab_d1_delegation.py; agent-coordination.md and agent-manual do
not yet cover delegation, and a full doc pass should follow this slice rather
than block it.
…, fail-closed re-check, TOCTOU set_sponsor, task release, high-entropy invite IDs (jaylfc#2048)
…d + tighten scope allowlist

BLOCKING: Approved delegation invites never reached the requester.
- Add send_envelope() to peer.py for reusable outbound peer delivery.
- Add delegation_status envelope kind on both outbound (peer.py) and
  inbound (routes/peer.py) sides.
- Wire invite_id through _apply_collab_delegation_grant: after mint
  succeeds, deliver delegation_status envelope to requester via the
  peer channel so their agent runner can redeem the invite.

MEDIUM: set_sponsor guard prevented cross-project re-parenting.
- Allow re-parenting to a DIFFERENT sponsor (handle-based reuse across
  projects).  The old guard blocked any change, so an identity first
  sponsored by contact A that joined B's project stayed stamped A,
  making cascade_sponsor_revoke(B) miss it.
- Set-same-sponsor is still a no-op; clearing (None) still works.
- Caller in agent_auth_requests.py now handles None return.

LOW: Unknown delegation scopes rode through to the mint.
- Replace permissive elevated-scope logging with VALID_SCOPES gate
  in _validate_delegation_envelope_body — unknown scopes now 400
  instead of silently passing through bundled approve_deny cards.

Tests: test_set_sponsor_reparents_to_different_sponsor (replaces old
immutability test), test_set_sponsor_noop_on_same_sponsor (new).
…wance

The old test asserted that re-parenting was blocked; now it asserts that
re-parenting succeeds (cross-project identity reuse via handle).
…b:hub:<user>

build_envelope prefixes 'hub:' itself; send_envelope was passing the already
prefixed contact id through as to_username, producing a hub:hub:<user>
envelope that the receiver 403s. Strip the prefix before building the envelope.
establish_peer_link stores plain URL strings (endpoints: list[str]), but
send_envelope iterated them as {'url', 'priority'} dicts, so sorted(...)
raised AttributeError outside its own try. Treat endpoints as strings.
…edeem

The receiver of a delegation_status envelope only logged and echoed the
invite_id; nothing persisted it, so the requester's agent runner had no record
to poll. Persist a decision with invite_id in metadata (mirroring collab_invite)
and drop the now-unused to_username in _deliver_delegation_invite.
send_envelope / _deliver_delegation_invite had zero coverage, which is why the
double-prefix and endpoint-shape bugs survived green. Add regression tests that
drive the real outbound path against a stub inbox (delivered is True, envelope
addressed hub:<user>) and assert delegation_status persists a durable decision.
…ic endpoint ordering

send_envelope now raises ValueError when to_contact_id is not a hub:<user>
id (previously removeprefix no-op'd and produced a hub:peer:abc envelope
that the receiver silently 403s).  Endpoint iteration is deterministic:
plain URL strings keep their declaration order, and dict endpoints (with
an optional priority) are sorted lowest-priority-first so the declared
primary endpoint is always tried before a fallback.
…perly

- Add DecisionStore.find_by_metadata(key, value) (json_extract lookup) and
  use it so a retried delegation_status envelope for the same invite_id
  returns the existing decision instead of minting a duplicate.
- Pass project_id and user_id (project owner) through to create so the row
  is project-scoped and owned, not NULL/ownerless.
- Tag the notification from_agent="system:delegation" (a system notification
  about an approval, not a request from the peer); originating contact stays
  in metadata.contact_id.
- idempotency: retried delegation_status returns the existing decision_id
  without a duplicate row.
- project scoping + attribution: decision carries project_id, owner user_id,
  and from_agent=system:delegation (contact kept in metadata).
- send_envelope: non-hub contact ids raise ValueError; dict endpoints are
  tried lowest-priority-first.
- DecisionStore.find_by_metadata: exact JSON-key match, no substring false
  positives.
…ork, jaylfc#2048)

Replace the single sponsor_contact_id column on agent_registry with an
agent_sponsorship association keyed by (canonical_id, project_id), so one
agent identity reused by handle across projects can be sponsored
independently by each project's delegating contact.

- v8 migration backfills the association from the legacy sponsor_contact_id
  column (keyed to the empty project_id sentinel), reproducing the old
  single-sponsor cascade for pre-change rows.
- list_by_sponsor resolves through the association.
- set_sponsorship writes INSERT OR IGNORE (atomic first-writer-wins on the
  PRIMARY KEY); remove_sponsorship + list_sponsorships_for_identity /
  list_sponsorships_by_contact added.
- cascade_sponsor_revoke removes the contact's association rows and revokes
  the identity only when no association remains.
- approve_request_record records sponsorship via the association instead of
  a column stamp.
…project) association, jaylfc#2048)

Cover jaylfc's acceptance criteria:
- two contacts / two projects / one handle: A's revoke spares the identity
  under B's sponsorship; B's revoke kills it outright.
- project-scoped revoke spares other projects.
- per-contact kill-switch observable through agent_token_auth._verify_agent_scope
  refusing a live bearer (403), not list_by_sponsor row count.
- concurrency: two writes for the same (identity, project) leave exactly one
  row with a deterministic first-writer-wins winner.
- v8 migration backfill reproduces the old single-sponsor cascade on a
  pre-change row.

Replaces the old TestSetSponsorAtomicGuard 'in (hub:A, hub:B)' assertion.
@hognek
hognek force-pushed the feat/collab-d1-agent-delegation branch from a61324c to eb80bf9 Compare September 1, 2026 10:49
@hognek

hognek commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

@-

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lead-blocked Lead has blocked this PR; gate_merge.sh refuses at exit 10.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants