diff --git a/CHANGELOG.md b/CHANGELOG.md index 49e5e5f..d9d9f21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,40 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +## [0.4.109] - 2026-03-19 + +### Hardened +- **Encryption helper robustness** — `DataEncryptor.encrypt()` and `decrypt()` handle `None` inputs gracefully. Large-payload warnings alert operators before performance-sensitive paths. Debug logging no longer includes raw metadata. + +## [0.4.108] - 2026-03-19 + +### Hardened +- **Delete signal authorization** — Inbound P2P delete signals for channel messages now verify requester ownership (message author or channel admin). Revocation signals are prioritised in the store-and-forward queue to survive offline-peer overflow. + +### Performance +- **Sidebar rendering efficiency** — DM contacts and peer list use DocumentFragment batching and render-key diffing to skip unnecessary DOM writes. Attention poll interval relaxed from 2.5s to 5s. GPU compositing hints added to animated sidebar elements. + +## [0.4.107] - 2026-03-19 + +### Hardened +- **Trust boundary enforcement** — Delete-signal compliance and violation handlers verify signal ownership before adjusting trust scores. Manually penalised peers are locked from automated trust recovery. Trust score operations validate against non-existent records. +- **P2P input validation** — Inbound messages enforce payload size limits (512 KB total, 256 KB content, 512-byte IDs). Feed posts with private or custom visibility are rejected at the P2P layer. Author identity is verified against origin peer on inbound feed posts. Delete signal handlers verify requester ownership across all data types. +- **API authentication tightening** — All P2P status endpoints require authentication. Session-based API key generation validates CSRF tokens. +- **Feed visibility defaults** — `can_view()` defaults to untrusted, requiring callers to pass explicit trust context. `get_user_posts()` applies standard visibility filters. Feed statistics include custom-visibility posts the viewer has permission to see. + +### Performance +- **Channel rendering** — O(n) orphan-reply check via Set lookup (previously O(n²)). `displayMessages` returns its Promise for proper search-banner chaining. + +## [0.4.106] - 2026-03-18 + +### Changed +- **Privacy-first trust baseline** — Unknown peers now start at trust score 0 (pending review) instead of 100 (implicitly trusted). `is_peer_trusted()` requires an explicit trust row before a peer qualifies. The Trust UI now separates connected-but-unreviewed peers into a "Potential peers" queue rather than placing them into trust tiers by default. +- **Feed defaults to private** — Feed post creation defaults to `private` ("Only Me") across UI, API, and MCP. Agents and users that omit visibility no longer broadcast unintentionally. Helper text in the feed composer clarifies the default and explains trusted sharing. +- **Trusted feed visibility consistency** — All feed query paths (`get_user_feed`, `search_posts`, `count_unread_posts`, `get_feed_statistics`, `_get_smart_feed`, `get_posts_since`) now include `trusted` visibility so trusted posts are no longer inconsistently omitted. +- **Targeted feed propagation** — `broadcast_feed_post()` now computes target peers by visibility scope: public/network → all connected, trusted → only peers meeting the trust threshold, private/custom → no P2P broadcast. Catch-up sync includes trusted posts only for explicitly trusted peers. +- **Feed visibility narrowing revocation** — When a post is edited from a broader to a narrower visibility, peers that are no longer in scope receive a delete signal. Update call sites in UI, API, and MCP now pass `previous_visibility` so revocation logic can run. +- **Operator copy clarity** — Settings advise using a separate node for public relay. Channel privacy descriptions clarify that Guarded is moderated/mesh-visible (not private) and Private is for sensitive work. + ## [0.4.105] - 2026-03-18 ### Fixed diff --git a/README.md b/README.md index dc8f4ac..0b9a00c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

- Version 0.4.105 + Version 0.4.109 Python 3.10+ Apache 2.0 License ChaCha20-Poly1305 @@ -31,6 +31,8 @@ > **Early-stage software.** Canopy is actively developed and evolving quickly. Use it for real workflows, but expect sharp edges and keep backups. See [LICENSE](LICENSE) for terms. +> **No tokens, no coins, no crypto.** Canopy is a free, open-source communication tool. It has no cryptocurrency, no blockchain, no token, and no paid tier. Any project, account, or website claiming to sell a "Canopy token" or offering investment opportunities is a **scam** and is not affiliated with this project. Report imposters to [GitHub Support](https://support.github.com). + --- ## At A Glance diff --git a/canopy/__init__.py b/canopy/__init__.py index 9b82228..1ad7a22 100644 --- a/canopy/__init__.py +++ b/canopy/__init__.py @@ -11,7 +11,7 @@ Development: AI-assisted implementation (Claude, Codex, GitHub Copilot, Cursor IDE, Ollama) """ -__version__ = "0.4.105" +__version__ = "0.4.109" __protocol_version__ = 1 __author__ = "Canopy Contributors" __license__ = "Apache-2.0" diff --git a/canopy/api/routes.py b/canopy/api/routes.py index 5dcd2ee..0ce50c7 100644 --- a/canopy/api/routes.py +++ b/canopy/api/routes.py @@ -1939,6 +1939,7 @@ def system_info(): # P2P Network endpoints @api.route('/p2p/status', methods=['GET']) + @require_auth(allow_session=True) def get_p2p_status(): """Get P2P network status.""" *_, p2p_manager = _get_app_components_any(current_app) @@ -2862,6 +2863,7 @@ def generate_api_key(): else: session_user = session.get('user_id') if session_user: + validate_csrf_request() user_id = session_user else: return jsonify({ @@ -2904,6 +2906,9 @@ def generate_api_key(): return jsonify({'error': 'Failed to generate API key'}), 500 except Exception as e: + from werkzeug.exceptions import HTTPException + if isinstance(e, HTTPException): + raise logger.error(f"Failed to generate API key: {e}") return jsonify({'error': 'Internal server error'}), 500 @@ -3600,7 +3605,8 @@ def get_peer_trust(peer_id): return jsonify({ 'peer_id': peer_id, 'trust_score': score, - 'is_trusted': is_trusted + 'is_trusted': is_trusted, + 'has_explicit_score': trust_manager.has_explicit_trust_score(peer_id), }) except Exception as e: @@ -3751,7 +3757,7 @@ def create_feed_post(): content = data.get('content') post_type = data.get('post_type', 'text') - visibility = data.get('visibility', 'network') + visibility = data.get('visibility', 'private') permissions = data.get('permissions', []) metadata = data.get('metadata') expires_at = data.get('expires_at') @@ -3776,7 +3782,7 @@ def create_feed_post(): try: vis = PostVisibility(visibility) except ValueError: - vis = PostVisibility.NETWORK + vis = PostVisibility.PRIVATE # Auto-detect poll posts when content matches poll format if pt == PostType.TEXT and parse_poll(content or ''): @@ -6409,6 +6415,7 @@ def update_feed_post(post_id): content=updated.content, post_type=updated.post_type.value, visibility=updated.visibility.value, + previous_visibility=post.visibility.value if getattr(post, 'visibility', None) else None, timestamp=updated.created_at.isoformat() if hasattr(updated.created_at, 'isoformat') else str(updated.created_at), metadata=updated.metadata, expires_at=updated.expires_at.isoformat() if getattr(updated, 'expires_at', None) else None, diff --git a/canopy/core/app.py b/canopy/core/app.py index b0b983b..c4b8727 100644 --- a/canopy/core/app.py +++ b/canopy/core/app.py @@ -4062,14 +4062,21 @@ def _on_catchup_request(channel_timestamps, from_peer, # Feed posts newer than what the peer has try: since_feed = feed_latest or '1970-01-01 00:00:00' + visible_feed_modes = ['network', 'public'] + try: + if trust_manager and trust_manager.is_peer_trusted(str(from_peer or '').strip()): + visible_feed_modes.append('trusted') + except Exception: + pass with db_manager.get_connection() as conn: + placeholders = ",".join("?" for _ in visible_feed_modes) rows = conn.execute( "SELECT id, author_id, content, content_type, " "visibility, metadata, created_at, expires_at " "FROM feed_posts WHERE created_at > ? AND " - "(visibility = 'network' OR visibility = 'public') " + f"visibility IN ({placeholders}) " "ORDER BY created_at ASC LIMIT 200", - (since_feed,) + (since_feed, *visible_feed_modes) ).fetchall() if rows: feed_posts = [] @@ -4899,6 +4906,36 @@ def _on_p2p_feed_post(post_id, author_id, content, post_type, display_name, from_peer): """Store an incoming P2P feed post locally. Updates content/metadata when post already exists (edit broadcast).""" try: + # --- Input validation --- + + # Reject posts with private/custom visibility over P2P + vis_str = str(visibility or '').lower() + if vis_str in ('private', 'custom'): + logger.warning(f"Rejecting P2P feed post {post_id} with visibility={vis_str} from {from_peer}") + return + + # ID length limits + for label, val in [('post_id', post_id), ('author_id', author_id)]: + if val and len(str(val).encode('utf-8')) > 512: + logger.warning(f"Rejecting P2P feed post: {label} too long from {from_peer}") + return + + # Content size limit (256 KB) + if content and len(str(content).encode('utf-8')) > 256 * 1024: + logger.warning(f"Rejecting oversized P2P feed post content from {from_peer}") + return + + # Metadata size limit (64 KB) + if metadata: + import json as _json + try: + meta_size = len(_json.dumps(metadata).encode('utf-8')) + if meta_size > 64 * 1024: + logger.warning(f"Rejecting oversized P2P feed post metadata from {from_peer}") + return + except Exception: + pass + # Ensure shadow user exists (reuse channel message logic) feed_origin_peer = '' try: @@ -4914,6 +4951,18 @@ def _on_p2p_feed_post(post_id, author_id, content, post_type, allow_origin_reassign=True, ) + # Author-ID spoofing prevention: verify the claimed author + # belongs to the sending peer + author_row = db_manager.get_user(author_id) + if author_row: + origin = (author_row.get('origin_peer') or '').strip() + if origin and origin != str(from_peer or '').strip(): + logger.warning( + f"Rejecting P2P feed post {post_id}: author {author_id} " + f"origin_peer={origin} != from_peer={from_peer}" + ) + return + # Normalise timestamp normalised_ts = None created_dt = None @@ -6148,6 +6197,14 @@ def _on_p2p_direct_message(sender_id, recipient_id, content, p2p_manager.on_direct_message = _on_p2p_direct_message # --- Delete signal handler --- + def _requester_owns_user(owner_user_id: str, from_peer_id: str) -> bool: + """Check that owner_user_id's origin_peer matches from_peer_id.""" + urow = db_manager.get_user(owner_user_id) + if not urow: + return False + origin = (urow.get('origin_peer') or '').strip() + return bool(origin) and origin == str(from_peer_id or '').strip() + def _on_delete_signal(signal_id, data_type, data_id, reason, requester_peer, is_ack, ack_status, from_peer): """Handle incoming DELETE_SIGNAL from a peer. @@ -6159,6 +6216,12 @@ def _on_delete_signal(signal_id, data_type, data_id, reason, We update our local signal status and adjust trust score. """ try: + # ID length limits + for label, val in [('signal_id', signal_id), ('data_id', data_id)]: + if val and len(str(val).encode('utf-8')) > 512: + logger.warning(f"Rejecting delete signal: {label} too long from {from_peer}") + return + if is_ack: # --- Acknowledgment from a peer --- status = ack_status or 'acknowledged' @@ -6192,26 +6255,48 @@ def _on_delete_signal(signal_id, data_type, data_id, reason, elif data_type == 'channel_message': # Delete a specific channel message (explicit type). - # Remove FK references first: likes and parent_message_id. + # Security: only the message's origin peer or the channel's + # origin peer (admin) may request deletion. try: channel_id = None with db_manager.get_connection() as conn: row = conn.execute( - "SELECT channel_id FROM channel_messages WHERE id = ?", + "SELECT channel_id, user_id FROM channel_messages WHERE id = ?", (data_id,), ).fetchone() if row: channel_id = row['channel_id'] if hasattr(row, 'keys') else row[0] - conn.execute("DELETE FROM likes WHERE message_id = ?", (data_id,)) - conn.execute( - "UPDATE channel_messages SET parent_message_id = NULL WHERE parent_message_id = ?", - (data_id,), - ) - cur = conn.execute( - "DELETE FROM channel_messages WHERE id = ?", - (data_id,)) - conn.commit() - deleted = cur.rowcount > 0 + msg_user_id = row['user_id'] if hasattr(row, 'keys') else row[1] + requester = str(requester_peer or from_peer or '').strip() + msg_authorized = _requester_owns_user(msg_user_id, requester) + ch_row = conn.execute( + "SELECT origin_peer FROM channels WHERE id = ?", + (channel_id,), + ).fetchone() + ch_origin = '' + if ch_row: + ch_origin = (ch_row['origin_peer'] if hasattr(ch_row, 'keys') else ch_row[0]) or '' + ch_admin = bool(ch_origin) and requester == ch_origin + if not msg_authorized and not ch_admin: + logger.warning( + "SECURITY: Rejected channel_message delete for %s " + "(requester=%s, msg_user=%s, ch_origin=%s)", + data_id, requester, msg_user_id, ch_origin, + ) + deleted = False + else: + conn.execute("DELETE FROM likes WHERE message_id = ?", (data_id,)) + conn.execute( + "UPDATE channel_messages SET parent_message_id = NULL WHERE parent_message_id = ?", + (data_id,), + ) + cur = conn.execute( + "DELETE FROM channel_messages WHERE id = ?", + (data_id,)) + conn.commit() + deleted = cur.rowcount > 0 + else: + deleted = True # Already gone, idempotent if deleted and channel_id: try: channel_manager._emit_channel_user_event( @@ -6241,15 +6326,21 @@ def _on_delete_signal(signal_id, data_type, data_id, reason, logger.error(f"Failed to delete file {data_id}: {del_err}") elif data_type in ('feed_post', 'post'): - # Delete a feed post + # Delete a feed post — verify requester owns the author try: deleted_post = feed_manager.get_post(data_id) if feed_manager else None - with db_manager.get_connection() as conn: - cur = conn.execute( - "DELETE FROM feed_posts WHERE id = ?", - (data_id,)) - conn.commit() - deleted = cur.rowcount > 0 + if deleted_post and not _requester_owns_user(deleted_post.author_id, from_peer): + logger.warning( + f"SECURITY: Rejected feed post delete for {data_id}: " + f"author={deleted_post.author_id} not owned by {from_peer}" + ) + elif deleted_post or not feed_manager: + with db_manager.get_connection() as conn: + cur = conn.execute( + "DELETE FROM feed_posts WHERE id = ?", + (data_id,)) + conn.commit() + deleted = cur.rowcount > 0 if deleted and feed_manager and deleted_post: try: feed_manager._emit_post_event( diff --git a/canopy/core/database.py b/canopy/core/database.py index 33ea0e2..6694725 100644 --- a/canopy/core/database.py +++ b/canopy/core/database.py @@ -144,11 +144,12 @@ def _initialize_database(self) -> None: CREATE TABLE IF NOT EXISTS trust_scores ( id INTEGER PRIMARY KEY AUTOINCREMENT, peer_id TEXT NOT NULL, - score INTEGER DEFAULT 100, + score INTEGER DEFAULT 0, last_interaction TIMESTAMP DEFAULT CURRENT_TIMESTAMP, compliance_events INTEGER DEFAULT 0, violation_events INTEGER DEFAULT 0, notes TEXT, + manually_penalized BOOLEAN NOT NULL DEFAULT 0, UNIQUE(peer_id) ); @@ -851,6 +852,13 @@ def _run_migrations(self, conn: sqlite3.Connection) -> None: if self._identity_portability_enabled(): self._ensure_identity_portability_schema(conn) + # --- Trust scores: add manually_penalized column --- + trust_cursor = conn.execute("PRAGMA table_info(trust_scores)") + trust_columns = [row[1] for row in trust_cursor.fetchall()] + if 'manually_penalized' not in trust_columns: + logger.info("Migration: Adding manually_penalized column to trust_scores") + conn.execute("ALTER TABLE trust_scores ADD COLUMN manually_penalized BOOLEAN NOT NULL DEFAULT 0") + conn.commit() except Exception as e: logger.critical( @@ -1549,7 +1557,7 @@ def update_trust_score(self, peer_id: str, score_delta: int, reason: Optional[st with self.get_connection() as conn: conn.execute(""" INSERT INTO trust_scores (peer_id, score, notes) - VALUES (?, 100 + ?, ?) + VALUES (?, max(0, min(100, ?)), ?) ON CONFLICT(peer_id) DO UPDATE SET score = max(0, min(100, score + ?)), last_interaction = CURRENT_TIMESTAMP, @@ -1564,7 +1572,7 @@ def get_trust_score(self, peer_id: str) -> int: "SELECT score FROM trust_scores WHERE peer_id = ?", (peer_id,) ) row = cursor.fetchone() - return row['score'] if row else 100 # Default trust score + return row['score'] if row else 0 # Unknown peers are pending review def get_all_trust_scores(self) -> Dict[str, int]: """Get all trust scores.""" @@ -1589,7 +1597,7 @@ def create_delete_signal(self, signal_id: str, target_peer_id: str, return False def update_delete_signal_status(self, signal_id: str, status: str) -> bool: - """Update delete signal status.""" + """Update delete signal status. Returns False if signal_id not found.""" VALID_STATUS_COLUMNS = { 'pending': 'sent_at', 'acknowledged': 'acknowledged_at', @@ -1601,18 +1609,21 @@ def update_delete_signal_status(self, signal_id: str, status: str) -> bool: with self.get_connection() as conn: timestamp_col = VALID_STATUS_COLUMNS.get(status) if timestamp_col: - conn.execute(f""" + cur = conn.execute(f""" UPDATE delete_signals SET status = ?, {timestamp_col} = CURRENT_TIMESTAMP WHERE id = ? """, (status, signal_id)) else: - conn.execute(""" + cur = conn.execute(""" UPDATE delete_signals SET status = ? WHERE id = ? """, (status, signal_id)) conn.commit() + if cur.rowcount == 0: + logger.warning(f"Delete signal {signal_id} not found for status update") + return False return True except Exception as e: logger.error(f"Failed to update delete signal: {e}") diff --git a/canopy/core/feed.py b/canopy/core/feed.py index da559d3..3865dbe 100644 --- a/canopy/core/feed.py +++ b/canopy/core/feed.py @@ -231,8 +231,12 @@ def to_dict(self) -> Dict[str, Any]: 'tags': self.tags_list, } - def can_view(self, viewer_id: str, trust_score: int = 50) -> bool: - """Check if a user can view this post based on visibility settings.""" + def can_view(self, viewer_id: str, trust_score: int = 0) -> bool: + """Check if a user can view this post based on visibility settings. + + Default trust_score is 0 (untrusted) so callers must explicitly + pass the viewer's actual trust level to allow trusted-visibility access. + """ if self.visibility == PostVisibility.PUBLIC: return True elif self.visibility == PostVisibility.NETWORK: @@ -387,7 +391,7 @@ def _resolve_expiry(self, @log_performance('feed') def create_post(self, author_id: str, content: str, post_type: PostType = PostType.TEXT, - visibility: PostVisibility = PostVisibility.NETWORK, + visibility: PostVisibility = PostVisibility.PRIVATE, metadata: Optional[Dict[str, Any]] = None, permissions: Optional[List[str]] = None, source_type: str = 'human', @@ -579,6 +583,7 @@ def get_user_feed(self, user_id: str, limit: int = 50, WHERE ( p.visibility = 'public' OR p.visibility = 'network' OR + p.visibility = 'trusted' OR (p.visibility = 'custom' AND pp.user_id = ?) OR p.author_id = ? ) AND (p.expires_at IS NULL OR p.expires_at > CURRENT_TIMESTAMP) @@ -632,6 +637,7 @@ def get_posts_since(self, user_id: str, since: datetime, limit: int = 50) -> Lis WHERE ( p.visibility = 'public' OR p.visibility = 'network' OR + p.visibility = 'trusted' OR (p.visibility = 'custom' AND pp.user_id = ?) OR p.author_id = ? ) @@ -663,6 +669,7 @@ def _get_smart_feed(self, user_id: str, limit: int, conn: Any) -> List[Post]: WHERE ( p.visibility = 'public' OR p.visibility = 'network' OR + p.visibility = 'trusted' OR (p.visibility = 'custom' AND pp.user_id = ?) OR p.author_id = ? ) AND (p.expires_at IS NULL OR p.expires_at > CURRENT_TIMESTAMP) {max_age_clause} @@ -687,22 +694,48 @@ def _get_smart_feed(self, user_id: str, limit: int, conn: Any) -> List[Post]: scored.sort(key=lambda x: x[0], reverse=True) return [post for _, post in scored[:limit]] - def get_user_posts(self, author_id: str, limit: int = 50) -> List[Post]: - """Get all posts by a specific user.""" + def get_user_posts(self, author_id: str, limit: int = 50, + viewer_id: Optional[str] = None) -> List[Post]: + """Get posts by a specific user, filtered by visibility. + + When viewer_id is provided, applies the standard visibility filter + so private/custom posts are only returned to authorised viewers. + When viewer_id is None, only public/network/trusted posts are returned. + """ try: with self.db.get_connection() as conn: - cursor = conn.execute(""" - SELECT p.*, u.username as author_username - FROM feed_posts p - LEFT JOIN users u ON p.author_id = u.id - WHERE p.author_id = ? - AND (p.expires_at IS NULL OR p.expires_at > CURRENT_TIMESTAMP) - ORDER BY p.created_at DESC - LIMIT ? - """, (author_id, limit)) - + if viewer_id: + cursor = conn.execute(""" + SELECT p.*, u.username as author_username + FROM feed_posts p + LEFT JOIN users u ON p.author_id = u.id + LEFT JOIN post_permissions pp ON p.id = pp.post_id + WHERE p.author_id = ? + AND (p.expires_at IS NULL OR p.expires_at > CURRENT_TIMESTAMP) + AND ( + p.visibility = 'public' + OR p.visibility = 'network' + OR p.visibility = 'trusted' + OR p.author_id = ? + OR (p.visibility = 'custom' AND pp.user_id = ?) + ) + ORDER BY p.created_at DESC + LIMIT ? + """, (author_id, viewer_id, viewer_id, limit)) + else: + cursor = conn.execute(""" + SELECT p.*, u.username as author_username + FROM feed_posts p + LEFT JOIN users u ON p.author_id = u.id + WHERE p.author_id = ? + AND (p.expires_at IS NULL OR p.expires_at > CURRENT_TIMESTAMP) + AND p.visibility IN ('public', 'network', 'trusted') + ORDER BY p.created_at DESC + LIMIT ? + """, (author_id, limit)) + return [self._row_to_post(row, conn) for row in cursor.fetchall()] - + except Exception as e: logger.error(f"Failed to get posts for user {author_id}: {e}") return [] @@ -992,6 +1025,7 @@ def search_posts(self, query: str, user_id: str, limit: int = 20) -> List[Post]: WHERE ( p.visibility = 'public' OR p.visibility = 'network' OR + p.visibility = 'trusted' OR (p.visibility = 'custom' AND pp.user_id = ?) OR p.author_id = ? ) AND (p.expires_at IS NULL OR p.expires_at > CURRENT_TIMESTAMP) @@ -1007,18 +1041,25 @@ def search_posts(self, query: str, user_id: str, limit: int = 20) -> List[Post]: return [] def get_feed_statistics(self, user_id: str) -> Dict[str, int]: - """Get feed statistics for a user.""" + """Get feed statistics for a user (includes custom-visibility posts).""" try: with self.db.get_connection() as conn: cursor = conn.execute(""" - SELECT - COUNT(*) as total_posts, - SUM(CASE WHEN author_id = ? THEN 1 ELSE 0 END) as user_posts, - COUNT(DISTINCT author_id) as unique_authors - FROM feed_posts - WHERE (visibility = 'public' OR visibility = 'network' OR author_id = ?) - AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) - """, (user_id, user_id)) + SELECT + COUNT(DISTINCT p.id) as total_posts, + SUM(CASE WHEN p.author_id = ? THEN 1 ELSE 0 END) as user_posts, + COUNT(DISTINCT p.author_id) as unique_authors + FROM feed_posts p + LEFT JOIN post_permissions pp ON p.id = pp.post_id + WHERE ( + p.visibility = 'public' + OR p.visibility = 'network' + OR p.visibility = 'trusted' + OR p.author_id = ? + OR (p.visibility = 'custom' AND pp.user_id = ?) + ) + AND (p.expires_at IS NULL OR p.expires_at > CURRENT_TIMESTAMP) + """, (user_id, user_id, user_id)) row = cursor.fetchone() return { @@ -1129,6 +1170,7 @@ def count_unread_posts(self, user_id: str, *, exclude_own_posts: bool = True) -> WHERE ( p.visibility = 'public' OR p.visibility = 'network' OR + p.visibility = 'trusted' OR (p.visibility = 'custom' AND pp.user_id = ?) OR p.author_id = ? ) diff --git a/canopy/core/inbox.py b/canopy/core/inbox.py index cd35780..bf0e25e 100644 --- a/canopy/core/inbox.py +++ b/canopy/core/inbox.py @@ -58,9 +58,8 @@ "allowed_trigger_types": ["mention", "dm", "reply", "channel_added"], "thread_reply_notifications": True, "auto_subscribe_own_threads": True, - # Mesh peers are implicitly trusted; TrustManager default_trust_score=100 - # so this is belt-and-suspenders, but disabling it avoids false rejections - # when a peer hasn't been explicitly added to trust_scores yet. + # Agent inbox delivery should not depend on manual trust review unless the + # operator explicitly enables trusted-only filtering for that account. "trusted_only": False, "min_trust_score": 0, # Very short cooldowns for agent use — agents need to react quickly. diff --git a/canopy/core/messaging.py b/canopy/core/messaging.py index 19fc5db..97dc5ad 100644 --- a/canopy/core/messaging.py +++ b/canopy/core/messaging.py @@ -398,7 +398,7 @@ def to_dict(self) -> Dict[str, Any]: logger.debug(f"Message {self.id}: Found {len(self.metadata['attachments'])} attachments") else: result['attachments'] = [] - logger.debug(f"Message {self.id}: No attachments found (metadata: {self.metadata})") + logger.debug(f"Message {self.id}: No attachments found") return result diff --git a/canopy/mcp/server.py b/canopy/mcp/server.py index 088bb61..52b64e3 100644 --- a/canopy/mcp/server.py +++ b/canopy/mcp/server.py @@ -58,6 +58,7 @@ build_agent_heartbeat_snapshot, build_actionable_work_preview, ) +from canopy.core.inbox import AGENT_SETTABLE_STATUSES from canopy.security.api_keys import Permission # Set up logging @@ -226,7 +227,7 @@ async def handle_list_tools() -> list[Tool]: inputSchema={ "type": "object", "properties": { - "status": {"type": "string", "description": "Filter by status (pending|handled|skipped|expired)"}, + "status": {"type": "string", "description": "Filter by status (pending|seen|completed|handled|skipped|expired)"}, "limit": {"type": "integer", "description": "Maximum items to retrieve (default: 50)", "default": 50}, "since": {"type": "string", "description": "Optional. ISO timestamp to fetch items after."}, "include_handled": {"type": "boolean", "description": "Include handled items (default: false)", "default": False} @@ -239,7 +240,7 @@ async def handle_list_tools() -> list[Tool]: inputSchema={ "type": "object", "properties": { - "status": {"type": "string", "description": "Filter by status (pending|handled|skipped|expired)"} + "status": {"type": "string", "description": "Filter by status (pending|seen|completed|handled|skipped|expired)"} } } ), @@ -290,12 +291,13 @@ async def handle_list_tools() -> list[Tool]: ), Tool( name="canopy_ack_inbox", - description="Update agent inbox items (mark handled/skipped/pending)", + description="Update agent inbox items (mark seen/completed/skipped/pending) with optional completion linkage", inputSchema={ "type": "object", "properties": { "ids": {"type": "array", "items": {"type": "string"}, "description": "Inbox item IDs"}, - "status": {"type": "string", "description": "New status (handled|skipped|pending)", "default": "handled"} + "status": {"type": "string", "description": "New status (seen|completed|skipped|pending); legacy alias 'handled' maps to completed. 'expired' is system-only and will be rejected.", "default": "handled"}, + "completion_ref": {"type": "object", "description": "Optional evidence link when completing or skipping work, e.g. {source_type, source_id, message_id, post_id}"} }, "required": ["ids"] } @@ -1883,9 +1885,7 @@ async def _rebuild_inbox(self, args: Dict[str, Any]) -> List[TextContent]: window_hours=window_hours, limit=limit, ) - pending_after = inbox_manager.count_items( - user_id=self.user_id, status='pending' - ) + pending_after = inbox_manager.count_items(user_id=self.user_id) result['pending_after'] = pending_after result['user_id'] = self.user_id result['window_hours'] = window_hours @@ -1900,6 +1900,12 @@ async def _ack_inbox(self, args: Dict[str, Any]) -> List[TextContent]: if not isinstance(ids, list) or not ids: return [TextContent(type="text", text="Error: ids must be a non-empty list")] status = args.get("status", "handled") + completion_ref = args.get("completion_ref") + if completion_ref is not None and not isinstance(completion_ref, dict): + return [TextContent(type="text", text="Error: completion_ref must be an object if provided")] + normalized_status = str(status or "").strip().lower() + if normalized_status not in AGENT_SETTABLE_STATUSES: + return [TextContent(type="text", text=f"Error: invalid status '{normalized_status}'. Must be one of: seen, completed, skipped, pending (or legacy alias handled)")] from canopy.core.app import create_app @@ -1912,6 +1918,7 @@ async def _ack_inbox(self, args: Dict[str, Any]) -> List[TextContent]: user_id=self.user_id, ids=ids, status=status, + completion_ref=completion_ref, ) payload = {"updated": count} return [TextContent(type="text", text=json.dumps(payload, indent=2))] @@ -2063,7 +2070,7 @@ def _parse_since(since_raw: Optional[str], window_hours: int) -> datetime: inbox_items = [] if inbox_manager: - inbox_items = inbox_manager.list_items(self.user_id, status='pending', limit=limit, since=since_iso, include_handled=False) + inbox_items = inbox_manager.list_items(self.user_id, limit=limit, since=since_iso, include_handled=False) task_items = [] if task_manager: @@ -2197,13 +2204,13 @@ def _parse_since(since_raw: Optional[str], window_hours: int) -> datetime: if inbox_manager: try: stats = inbox_manager.get_stats(self.user_id, window_hours=window_hours) - inbox_count = int((stats.get('status_counts') or {}).get('pending', 0)) + status_counts = stats.get('status_counts') or {} + inbox_count = int(status_counts.get('pending', 0) or 0) + int(status_counts.get('seen', 0) or 0) except Exception: inbox_count = 0 try: preview_items = inbox_manager.list_items( user_id=self.user_id, - status='pending', limit=5, since=since_iso, include_handled=False, @@ -3966,11 +3973,14 @@ async def _post_to_feed(self, args: Dict[str, Any]) -> List[TextContent]: if not content: raise ValueError("content is required") post_type_str = (args.get("post_type") or "text").lower() - visibility_str = (args.get("visibility") or "network").lower() + visibility_str = (args.get("visibility") or "private").lower() post_type = PostType.TEXT if post_type_str in {"poll"} or parse_poll(content or ""): post_type = PostType.POLL - visibility = PostVisibility.NETWORK if visibility_str == "network" else PostVisibility.NETWORK + try: + visibility = PostVisibility(visibility_str) + except Exception: + visibility = PostVisibility.PRIVATE expires_at = args.get("expires_at") ttl_seconds = args.get("ttl_seconds") ttl_mode = args.get("ttl_mode") @@ -4103,6 +4113,7 @@ async def _update_feed_post(self, args: Dict[str, Any]) -> List[TextContent]: except Exception: pass + previous_post = feed_manager.get_post(post_id) try: metadata["edited_at"] = datetime.now(timezone.utc).isoformat() except Exception: @@ -4134,6 +4145,7 @@ async def _update_feed_post(self, args: Dict[str, Any]) -> List[TextContent]: content=updated.content, post_type=updated.post_type.value, visibility=updated.visibility.value, + previous_visibility=previous_post.visibility.value if getattr(previous_post, "visibility", None) else None, timestamp=updated.created_at.isoformat() if hasattr(updated.created_at, "isoformat") else str(updated.created_at), metadata=updated.metadata, expires_at=updated.expires_at.isoformat() if getattr(updated, "expires_at", None) else None, diff --git a/canopy/network/manager.py b/canopy/network/manager.py index 6dc9f04..b8c092d 100644 --- a/canopy/network/manager.py +++ b/canopy/network/manager.py @@ -1872,13 +1872,17 @@ def _on_broker_request(self, target_peer: str, logger.info(f"Broker request from {from_peer} declined (relay_policy=off)") return - # Check trust score — decline relay for low-trust peers + # Privacy-first relay posture: unknown peers do not get broker help. if self.get_trust_score: try: score = self.get_trust_score(from_peer) - if score < 20: + threshold = max( + 1, + int(getattr(getattr(self.config, 'security', None), 'trust_threshold', 50) or 50), + ) + if score < threshold: logger.warning(f"Broker request from {from_peer} declined " - f"(trust score {score} < 20)") + f"(trust score {score} < {threshold})") return except Exception: pass @@ -2650,6 +2654,7 @@ def broadcast_channel_message(self, channel_id: str, user_id: str, def broadcast_feed_post(self, post_id: str, author_id: str, content: str, post_type: str = 'text', visibility: str = 'network', + previous_visibility: Optional[str] = None, timestamp: Optional[str] = None, metadata: Optional[dict[Any, Any]] = None, expires_at: Optional[str] = None, @@ -2666,12 +2671,14 @@ def broadcast_feed_post(self, post_id: str, author_id: str, if not self.message_router: return False + visibility_mode = str(visibility or 'private').strip().lower() or 'private' + previous_visibility_mode = str(previous_visibility or visibility_mode).strip().lower() or visibility_mode meta: Dict[str, Any] = { 'type': 'feed_post', 'post_id': post_id, 'author_id': author_id, 'post_type': post_type, - 'visibility': visibility, + 'visibility': visibility_mode, 'timestamp': timestamp, 'metadata': metadata or {}, 'expires_at': expires_at, @@ -2706,10 +2713,60 @@ def broadcast_feed_post(self, post_id: str, author_id: str, except Exception as e: logger.debug(f"Feed attachment embedding failed: {e}") - future = asyncio.run_coroutine_threadsafe( - self.message_router.send_feed_post_broadcast(content, meta), - self._event_loop + target_peers, revoke_peers = self._get_feed_post_target_delta( + previous_visibility_mode, + visibility_mode, ) + if visibility_mode == 'trusted' and not target_peers: + logger.info( + "Feed post %s visibility=trusted has no trusted connected peers; keeping local only", + post_id, + ) + + async def _send_feed_post() -> bool: + sent_any = False + if visibility_mode in {'public', 'network'}: + sent_any = await self.message_router.send_feed_post_broadcast(content, meta) + else: + for peer_id in target_peers: + payload = { + 'content': content, + 'metadata': dict(meta), + } + message = self.message_router.create_message( + MessageType.FEED_POST, + peer_id, + payload, + ttl=getattr(self.message_router, '_CONTENT_TTL', 5), + ) + self.message_router.sign_message(message) + if await self.message_router._route_to_peer(message): + sent_any = True + + revoked_any = False + if revoke_peers: + for peer_id in revoke_peers: + signal_id = secrets.token_hex(12) + if await self.message_router.send_delete_signal( + signal_id=signal_id, + data_type='feed_post', + data_id=post_id, + reason=f"visibility_narrowed:{previous_visibility_mode}->{visibility_mode}", + target_peer=peer_id, + ): + revoked_any = True + logger.info( + "Feed post %s revoked from %d peer(s) due to visibility change %s -> %s", + post_id, + len(revoke_peers), + previous_visibility_mode, + visibility_mode, + ) + if visibility_mode in {'private', 'custom'}: + return revoked_any or not revoke_peers + return sent_any or revoked_any + + future = asyncio.run_coroutine_threadsafe(_send_feed_post(), self._event_loop) try: return future.result(timeout=5.0) @@ -2717,6 +2774,45 @@ def broadcast_feed_post(self, post_id: str, author_id: str, logger.error(f"Error broadcasting feed post: {e}", exc_info=True) return False + def _get_feed_post_target_peers(self, visibility: str) -> list[str]: + """Return connected peer targets for a feed post visibility mode.""" + visibility_mode = str(visibility or 'private').strip().lower() or 'private' + if visibility_mode in {'private', 'custom'}: + return [] + peers = list(self.get_connected_peers()) + if visibility_mode in {'public', 'network'}: + return peers + if visibility_mode != 'trusted' or not self.get_trust_score: + return [] + threshold = max( + 1, + int(getattr(getattr(self.config, 'security', None), 'trust_threshold', 50) or 50), + ) + trusted_peers: list[str] = [] + for peer_id in peers: + if not peer_id: + continue + try: + if int(self.get_trust_score(peer_id)) >= threshold: + trusted_peers.append(peer_id) + except Exception: + continue + return trusted_peers + + def _get_feed_post_target_delta( + self, + previous_visibility: str, + visibility: str, + ) -> tuple[list[str], list[str]]: + """Return (target_peers, revoke_peers) for a feed visibility change.""" + target_peers = self._get_feed_post_target_peers(visibility) + previous_targets = self._get_feed_post_target_peers(previous_visibility) + if not previous_targets: + return target_peers, [] + target_set = set(target_peers) + revoke_peers = [peer_id for peer_id in previous_targets if peer_id not in target_set] + return target_peers, revoke_peers + def broadcast_interaction(self, item_id: str, user_id: str, action: str, item_type: str = 'post', display_name: Optional[str] = None, diff --git a/canopy/network/routing.py b/canopy/network/routing.py index 16966b1..648c7ca 100644 --- a/canopy/network/routing.py +++ b/canopy/network/routing.py @@ -130,6 +130,10 @@ def encode_channel_key_material(key_material: bytes) -> str: # Prevents a misbehaving or permanently-offline peer from exhausting RAM. MAX_PENDING_PER_PEER = 500 +MAX_CONTENT_BYTES = 256 * 1024 # 256 KB for text content +MAX_PAYLOAD_BYTES = 512 * 1024 # 512 KB for total message payload +MAX_ID_BYTES = 512 # max length for any user/post/signal ID + class MessageType(Enum): """Types of P2P messages.""" @@ -776,7 +780,17 @@ async def _route_to_peer(self, message: P2PMessage) -> bool: self.pending_messages[target_peer] = [] queue = self.pending_messages[target_peer] if len(queue) >= MAX_PENDING_PER_PEER: - dropped = queue.pop(0) + # Prioritise delete signals: evict the oldest non-delete-signal + # message so revocation/privacy signals survive queue overflow. + evict_idx = None + for i, m in enumerate(queue): + if m.type != MessageType.DELETE_SIGNAL: + evict_idx = i + break + if evict_idx is not None: + dropped = queue.pop(evict_idx) + else: + dropped = queue.pop(0) logger.warning( f"Pending queue for {target_peer} full ({MAX_PENDING_PER_PEER}); " f"dropped oldest message {dropped.id}" @@ -813,13 +827,33 @@ async def _deliver_local(self, message: P2PMessage) -> bool: """Deliver message to local application.""" logger.debug(f"Delivering message {message.id} locally") payload = cast(Dict[str, Any], message.payload) - + # Decrypt if needed if message.encrypted_payload: if not self.decrypt_message(message): logger.error(f"Failed to decrypt message {message.id}") return False - + + # Payload size guard: drop oversized messages before any handler runs + try: + import json as _json + payload_bytes = len(_json.dumps(payload).encode('utf-8')) + if payload_bytes > MAX_PAYLOAD_BYTES: + logger.warning( + f"Dropping oversized message {message.id} from {message.from_peer}: " + f"{payload_bytes} bytes exceeds {MAX_PAYLOAD_BYTES} limit" + ) + return False + content_val = payload.get('content', '') + if isinstance(content_val, str) and len(content_val.encode('utf-8')) > MAX_CONTENT_BYTES: + logger.warning( + f"Dropping message {message.id} from {message.from_peer}: " + f"content exceeds {MAX_CONTENT_BYTES} byte limit" + ) + return False + except Exception: + pass + logger.debug(f"Received {message.type.value} message from {message.from_peer}") # Emit a lightweight UI-facing activity event for user-level messages. diff --git a/canopy/security/encryption.py b/canopy/security/encryption.py index 07d63e0..b99ff57 100644 --- a/canopy/security/encryption.py +++ b/canopy/security/encryption.py @@ -93,24 +93,34 @@ def is_enabled(self) -> bool: """Check if encryption is currently enabled.""" return self._enabled - def encrypt(self, plaintext: str) -> str: + _LARGE_PAYLOAD_WARN_BYTES = 1 * 1024 * 1024 # 1 MiB + + def encrypt(self, plaintext: Optional[str]) -> Optional[str]: """ Encrypt a string for storage. Args: - plaintext: The string to encrypt + plaintext: The string to encrypt. Binary data must be + base64-encoded by the caller before passing here. Returns: - Encrypted string with prefix, or original string if encryption disabled + Encrypted string with prefix, or original string if encryption + disabled. Returns None when plaintext is None. """ + if plaintext is None: + return None if not self._enabled or not plaintext: return plaintext try: + plaintext_bytes = plaintext.encode('utf-8') + if len(plaintext_bytes) > self._LARGE_PAYLOAD_WARN_BYTES: + logger.warning( + f"Encrypting large payload ({len(plaintext_bytes)} bytes); " + "consider chunking or compressing before encryption" + ) cipher = ChaCha20Poly1305(cast(bytes, self._cipher_key)) nonce = secrets.token_bytes(12) - - plaintext_bytes = plaintext.encode('utf-8') ciphertext = cipher.encrypt(nonce, plaintext_bytes, None) # Format: ENC:1:: @@ -120,7 +130,7 @@ def encrypt(self, plaintext: str) -> str: logger.error(f"Encryption failed: {e}") return plaintext # Fail open - store unencrypted rather than lose data - def decrypt(self, stored_value: str) -> str: + def decrypt(self, stored_value: Optional[str]) -> Optional[str]: """ Decrypt a stored string. @@ -128,8 +138,11 @@ def decrypt(self, stored_value: str) -> str: stored_value: The stored string (possibly encrypted) Returns: - Decrypted string, or original string if not encrypted + Decrypted string, or original string if not encrypted. + Returns None when stored_value is None. """ + if stored_value is None: + return None if not stored_value or not stored_value.startswith(ENCRYPTED_PREFIX): return stored_value # Not encrypted, return as-is @@ -167,7 +180,7 @@ def decrypt(self, stored_value: str) -> str: logger.debug(f"Decryption failed (suppressed repeat, fingerprint={fingerprint}): {e}") return "[Decryption failed]" - def is_encrypted(self, value: str) -> bool: + def is_encrypted(self, value: Optional[str]) -> bool: """Check if a value is encrypted.""" return value is not None and value.startswith(ENCRYPTED_PREFIX) diff --git a/canopy/security/trust.py b/canopy/security/trust.py index 641096b..6e2fb69 100644 --- a/canopy/security/trust.py +++ b/canopy/security/trust.py @@ -76,13 +76,31 @@ class TrustManager: def __init__(self, db_manager: DatabaseManager): """Initialize trust manager with database connection.""" self.db = db_manager - self.default_trust_score = 100 + # Privacy-first baseline: unknown peers are pending review, not trusted. + self.default_trust_score = 0 self.min_trust_score = 0 self.max_trust_score = 100 self.delete_timeout_hours = 24 + + def has_explicit_trust_score(self, peer_id: str) -> bool: + """Return True when a peer has a persisted trust row.""" + if not peer_id: + return False + try: + with self.db.get_connection() as conn: + row = conn.execute( + "SELECT 1 FROM trust_scores WHERE peer_id = ?", + (peer_id,), + ).fetchone() + return bool(row) + except Exception as e: + logger.error(f"Failed to check trust row for {peer_id}: {e}") + return False def get_trust_score(self, peer_id: str) -> int: """Get current trust score for a peer.""" + if not peer_id: + return self.default_trust_score try: with self.db.get_connection() as conn: cursor = conn.execute(""" @@ -118,11 +136,17 @@ def update_trust_score(self, peer_id: str, event: TrustEvent, with self.db.get_connection() as conn: # Get current score or create new entry cursor = conn.execute(""" - SELECT score FROM trust_scores WHERE peer_id = ? + SELECT score, manually_penalized FROM trust_scores WHERE peer_id = ? """, (peer_id,)) row = cursor.fetchone() current_score = row['score'] if row else self.default_trust_score + manually_penalized = bool(row['manually_penalized']) if row and 'manually_penalized' in row.keys() else False + + # Block positive score changes for manually penalized peers + if manually_penalized and score_delta > 0: + logger.debug(f"Skipping positive trust delta for manually penalized peer {peer_id}") + return current_score # Calculate new score within bounds new_score = max( @@ -133,7 +157,7 @@ def update_trust_score(self, peer_id: str, event: TrustEvent, # Update or insert trust score if row: conn.execute(""" - UPDATE trust_scores + UPDATE trust_scores SET score = ?, last_interaction = CURRENT_TIMESTAMP, compliance_events = compliance_events + ?, violation_events = violation_events + ?, @@ -184,18 +208,20 @@ def set_trust_score(self, peer_id: str, score: int, reason: Optional[str] = None row = cursor.fetchone() note = f"manual: {reason}" if reason else "manual" + penalized = 1 if clamped_score < 50 else 0 if row: conn.execute(""" UPDATE trust_scores - SET score = ?, last_interaction = CURRENT_TIMESTAMP, notes = ? + SET score = ?, last_interaction = CURRENT_TIMESTAMP, + notes = ?, manually_penalized = ? WHERE peer_id = ? - """, (clamped_score, note, peer_id)) + """, (clamped_score, note, penalized, peer_id)) else: conn.execute(""" INSERT INTO trust_scores - (peer_id, score, compliance_events, violation_events, notes) - VALUES (?, ?, 0, 0, ?) - """, (peer_id, clamped_score, note)) + (peer_id, score, compliance_events, violation_events, notes, manually_penalized) + VALUES (?, ?, 0, 0, ?, ?) + """, (peer_id, clamped_score, note, penalized)) conn.commit() logger.info(f"Set trust score for {peer_id}: {clamped_score} ({note})") @@ -273,14 +299,26 @@ def acknowledge_delete_signal(self, signal_id: str) -> bool: return False def comply_with_delete_signal(self, signal_id: str, peer_id: str) -> bool: - """Mark a delete signal as complied with and update trust score.""" + """Mark a delete signal as complied with and update trust score. + + Ownership check: the signal's target_peer_id must match peer_id + to prevent a peer claiming compliance credit for someone else's signal. + """ try: + with self.db.get_connection() as conn: + row = conn.execute( + "SELECT target_peer_id FROM delete_signals WHERE id = ?", + (signal_id,) + ).fetchone() + if not row or row['target_peer_id'] != peer_id: + logger.warning(f"Ownership check failed for comply: signal={signal_id} peer={peer_id}") + return False + success = self.db.update_delete_signal_status(signal_id, "complied") if success: - # Reward compliance with positive trust score self.update_trust_score( - peer_id, - TrustEvent.DELETE_COMPLIED, + peer_id, + TrustEvent.DELETE_COMPLIED, reason=f"Complied with delete signal {signal_id}" ) logger.info(f"Marked delete signal {signal_id} as complied") @@ -288,16 +326,27 @@ def comply_with_delete_signal(self, signal_id: str, peer_id: str) -> bool: except Exception as e: logger.error(f"Failed to mark delete signal as complied: {e}") return False - + def violate_delete_signal(self, signal_id: str, peer_id: str) -> bool: - """Mark a delete signal as violated and penalize trust score.""" + """Mark a delete signal as violated and penalize trust score. + + Ownership check: the signal's target_peer_id must match peer_id. + """ try: + with self.db.get_connection() as conn: + row = conn.execute( + "SELECT target_peer_id FROM delete_signals WHERE id = ?", + (signal_id,) + ).fetchone() + if not row or row['target_peer_id'] != peer_id: + logger.warning(f"Ownership check failed for violate: signal={signal_id} peer={peer_id}") + return False + success = self.db.update_delete_signal_status(signal_id, "violated") if success: - # Penalize violation with negative trust score self.update_trust_score( - peer_id, - TrustEvent.DELETE_VIOLATED, + peer_id, + TrustEvent.DELETE_VIOLATED, reason=f"Violated delete signal {signal_id}" ) logger.warning(f"Marked delete signal {signal_id} as violated by {peer_id}") @@ -367,6 +416,8 @@ def check_expired_delete_signals(self) -> List[Tuple[str, str]]: def is_peer_trusted(self, peer_id: str, threshold: int = 50) -> bool: """Check if a peer is trusted based on their trust score.""" + if not self.has_explicit_trust_score(peer_id): + return False return self.get_trust_score(peer_id) >= threshold def get_trusted_peers(self, threshold: int = 50) -> List[str]: diff --git a/canopy/ui/routes.py b/canopy/ui/routes.py index 8e4136a..ebe85b0 100644 --- a/canopy/ui/routes.py +++ b/canopy/ui/routes.py @@ -3631,24 +3631,6 @@ def trust_management(): # Device profiles for peer identification peer_device_profiles = channel_manager.get_all_peer_device_profiles() if channel_manager else {} - # Ensure connected peers have entries - all_peer_ids = set(trust_scores.keys()) - for pid in connected_peers: - if pid: - all_peer_ids.add(pid) - if trust_manager: - for pid in all_peer_ids: - if pid not in trust_scores: - score = trust_manager.get_trust_score(pid) - trust_scores[pid] = { - 'score': score, - 'last_interaction': None, - 'compliance_events': 0, - 'violation_events': 0, - 'notes': 'discovered', - 'is_trusted': score >= 50 - } - # Get trust statistics trust_stats = trust_manager.get_trust_statistics() if trust_manager else {} @@ -3669,9 +3651,9 @@ def trust_management(): score = score_info.get('score', 0) if score >= 80: tier = 'safe' - elif score >= 60: - tier = 'guarded' elif score >= 40: + tier = 'guarded' + elif score > 0: tier = 'restricted' else: tier = 'quarantine' @@ -3680,10 +3662,15 @@ def trust_management(): # Potential peers list (introduced but not yet assigned a trust tier) potential_peers = [] existing_peer_ids = set(trust_scores.keys()) + for pid in connected_peers: + if pid and pid not in existing_peer_ids: + potential_peers.append({'peer_id': pid, 'status': 'connected'}) + existing_peer_ids.add(pid) for peer in introduced_peers: pid = peer.get('peer_id') if isinstance(peer, dict) else None if pid and pid not in existing_peer_ids: potential_peers.append(peer) + existing_peer_ids.add(pid) return render_template('trust.html', trust_scores=trust_scores, @@ -3722,9 +3709,9 @@ def trust_update(): if score is None: tier_map = { 'safe': 90, - 'guarded': 65, - 'restricted': 40, - 'quarantine': 10 + 'guarded': 40, + 'restricted': 10, + 'quarantine': 0 } if tier not in tier_map: return jsonify({'error': 'Invalid tier'}), 400 @@ -8565,7 +8552,7 @@ def ajax_create_post(): data = request.get_json() content = data.get('content', '').strip() post_type = data.get('post_type', 'text') - visibility = data.get('visibility', 'network') + visibility = data.get('visibility', 'private') permissions = data.get('permissions', []) metadata = data.get('metadata') file_attachments = data.get('attachments', []) @@ -8629,7 +8616,7 @@ def ajax_create_post(): # Convert visibility to PostVisibility enum try: - visibility_enum = PostVisibility(visibility if visibility in ['network', 'trusted', 'public', 'private', 'custom'] else 'network') + visibility_enum = PostVisibility(visibility if visibility in ['network', 'trusted', 'public', 'private', 'custom'] else 'private') except ValueError as e: return jsonify({'error': f'Invalid visibility: {e}'}), 400 @@ -9516,6 +9503,7 @@ def ajax_update_post(): content=updated.content, post_type=updated.post_type.value, visibility=updated.visibility.value, + previous_visibility=existing_post.visibility.value if getattr(existing_post, 'visibility', None) else None, timestamp=updated.created_at.isoformat() if hasattr(updated.created_at, 'isoformat') else str(updated.created_at), metadata=updated.metadata, expires_at=updated.expires_at.isoformat() if getattr(updated, 'expires_at', None) else None, diff --git a/canopy/ui/static/js/canopy-main.js b/canopy/ui/static/js/canopy-main.js index 06272fe..d25836c 100644 --- a/canopy/ui/static/js/canopy-main.js +++ b/canopy/ui/static/js/canopy-main.js @@ -670,9 +670,11 @@ } const visiblePeers = visibleSidebarCardItems('peers', activePeers); + const peerFrag = document.createDocumentFragment(); visiblePeers.forEach(record => { - listEl.appendChild(createSidebarPeerElement(record)); + peerFrag.appendChild(createSidebarPeerElement(record)); }); + listEl.appendChild(peerFrag); setSidebarPeerCount(activePeers.length); updateSidebarCardChrome('peers', activePeers.length); renderSidebarPeerModalList(); @@ -862,6 +864,16 @@ const totalUnread = normalized.reduce((sum, contact) => sum + Math.max(0, Number(contact && contact.unread_count) || 0), 0); if (totalEl) totalEl.textContent = String(totalUnread); + // Render-key diffing: skip DOM writes when data is unchanged + const dmRenderKey = visibleContacts.map(c => + `${c.user_id}:${c.unread_count}:${c.status_state}:${c.latest_preview}:${c.latest_message_at}` + ).join('|'); + if (listEl.__canopyDmRenderKey === dmRenderKey && listEl.childElementCount > 0) { + updateSidebarCardChrome('dm', normalized.length); + return; + } + listEl.__canopyDmRenderKey = dmRenderKey; + listEl.innerHTML = ''; if (!normalized.length) { const empty = document.createElement('div'); @@ -872,6 +884,7 @@ return; } + const dmFrag = document.createDocumentFragment(); visibleContacts.forEach(contact => { const link = document.createElement('a'); link.className = 'sidebar-dm-contact'; @@ -940,8 +953,9 @@ } link.appendChild(time); - listEl.appendChild(link); + dmFrag.appendChild(link); }); + listEl.appendChild(dmFrag); updateSidebarCardChrome('dm', normalized.length); } @@ -1308,7 +1322,7 @@ requestCanopySidebarAttentionRefresh({ force: false }).catch(() => {}); requestCanopySidebarDmRefresh({ force: false }).catch(() => {}); pollCanopyWorkspaceAttentionEvents(); - canopySidebarAttentionState.pollHandle = window.setInterval(pollCanopyWorkspaceAttentionEvents, 2500); + canopySidebarAttentionState.pollHandle = window.setInterval(pollCanopyWorkspaceAttentionEvents, 5000); canopySidebarAttentionState.safetyHandle = window.setInterval(() => { requestCanopySidebarAttentionRefresh({ force: false }).catch(() => {}); requestCanopySidebarDmRefresh({ force: false }).catch(() => {}); diff --git a/canopy/ui/templates/base.html b/canopy/ui/templates/base.html index 1f504e2..076766d 100644 --- a/canopy/ui/templates/base.html +++ b/canopy/ui/templates/base.html @@ -677,6 +677,7 @@ .sidebar-media-mini.is-visible { display: block; animation: miniPlayerSlideIn 0.28s cubic-bezier(0.2, 0.7, 0.2, 1); + will-change: transform, opacity; } @keyframes miniPlayerSlideIn { @@ -1026,6 +1027,7 @@ background: linear-gradient(90deg, rgba(6, 182, 212, 0.14), rgba(255, 255, 255, 0.03)); box-shadow: 0 0 0 1px rgba(6, 182, 212, 0.16), 0 12px 26px rgba(0, 0, 0, 0.22); animation: sidebarPeerPing 1.1s ease; + will-change: transform; } .sidebar-peer.activity .trust-pill { diff --git a/canopy/ui/templates/channels.html b/canopy/ui/templates/channels.html index ce331cf..2c66598 100644 --- a/canopy/ui/templates/channels.html +++ b/canopy/ui/templates/channels.html @@ -2519,11 +2519,11 @@

Guarded - Signed, limited metadata + Moderated and visible to the mesh. Not private.
  • Private - E2EE-ready (pilot) + Targeted membership. Use this for sensitive work.
  • Confidential @@ -6178,7 +6178,7 @@

    - + +
    Default is private. `Trusted` only reaches peers you have explicitly reviewed.
    @@ -5275,7 +5276,7 @@
    @@ -5398,7 +5400,7 @@

    Broker Only (recommended) helps peers find and establish direct connections with minimal bandwidth cost.
    Full Relay also carries traffic for peers that cannot connect directly, which uses your bandwidth and resources. +
    + For a public relay, use a separate node from your primary owner mesh.
    diff --git a/canopy/ui/templates/trust.html b/canopy/ui/templates/trust.html index d8aaa48..930279b 100644 --- a/canopy/ui/templates/trust.html +++ b/canopy/ui/templates/trust.html @@ -617,7 +617,7 @@

    See and shape your mesh at a glance

    Trust Zones

    -

    Layer your mesh so humans and agents stay inside the right permission band.

    +

    Manual trust bands for peers you have explicitly reviewed. New peers stay pending until you place them.

    Safe @@ -631,7 +631,7 @@

    Trust Zones

    Safe Zone

    -

    Full relay + private data access.

    +

    Explicitly trusted peers. Suitable for broker help and trusted-only sharing.

    {{ trust_tiers.safe|length }}
    @@ -648,7 +648,7 @@

    Safe Zone

    Guarded

    -

    Messaging ok, sensitive data gated.

    +

    Known peers under review. Do not treat this as a privacy boundary.

    {{ trust_tiers.guarded|length }}
    @@ -665,7 +665,7 @@

    Guarded

    Restricted

    -

    Limited participation, no relay.

    +

    Limited participation. Keep them off broker and relay paths.

    {{ trust_tiers.restricted|length }}
    @@ -727,7 +727,7 @@

    Quarantine

    Potential Peers
    -
    Introduced by your network. Drag into a zone to trust.
    +
    Connected or introduced peers without an explicit trust score. Drag into a zone only after review.
    {% if potential_peers %} {% for peer in potential_peers %} @@ -762,9 +762,9 @@

    Quarantine

    Trust Policy
      -
    • Safe peers can relay and access protected data.
    • -
    • Guarded peers can message but cannot relay.
    • -
    • Restricted peers are read-mostly and monitored.
    • +
    • Peers are pending by default until you assign an explicit trust score.
    • +
    • Safe peers can participate in trusted-only sharing and broker requests.
    • +
    • Guarded is a review state, not a private zone.
    • Quarantined peers stay isolated until reviewed.
    diff --git a/pyproject.toml b/pyproject.toml index ca773ed..4359200 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "canopy" -version = "0.4.105" +version = "0.4.109" description = "Local-first peer-to-peer collaboration for humans and AI agents." readme = "README.md" requires-python = ">=3.10"