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 @@
- 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. Full relay + private data access. Explicitly trusted peers. Suitable for broker help and trusted-only sharing. Messaging ok, sensitive data gated. Known peers under review. Do not treat this as a privacy boundary. Limited participation, no relay. Limited participation. Keep them off broker and relay paths.
+
@@ -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:
Guarded
- Signed, limited metadata
+ Moderated and visible to the mesh. Not private.
-
+
+
Add Community No
id: postId,
content: currentContent,
post_type: 'text',
- visibility: 'network',
+ visibility: 'private',
metadata: {},
});
})
@@ -5338,9 +5339,10 @@
Add Community No
-
+
+
Add Community No
}
const visibilitySelect = panel.querySelector('select[data-inline-post-visibility]');
if (visibilitySelect) {
- const visibilityValue = String(post.visibility || 'network');
+ const visibilityValue = String(post.visibility || 'private');
if (visibilitySelect.querySelector(`option[value="${visibilityValue}"]`)) {
visibilitySelect.value = visibilityValue;
}
@@ -5566,7 +5568,7 @@
Add Community No
const visibilitySelect = panel.querySelector('select[data-inline-post-visibility]');
const content = ((textarea && textarea.value) || '').trim();
const postType = ((typeSelect && typeSelect.value) || 'text').trim();
- const visibility = ((visibilitySelect && visibilitySelect.value) || 'network').trim();
+ const visibility = ((visibilitySelect && visibilitySelect.value) || 'private').trim();
if (!content && state.existingAttachments.length === 0 && state.newAttachments.length === 0) {
showAlert('Post content or attachments are required', 'warning');
diff --git a/canopy/ui/templates/settings.html b/canopy/ui/templates/settings.html
index 95eea2f..26b2497 100644
--- a/canopy/ui/templates/settings.html
+++ b/canopy/ui/templates/settings.html
@@ -432,6 +432,8 @@
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.
See and shape your mesh at a glance
Trust Zones
- Trust Zones
Safe Zone
- Safe Zone
Guarded
- Guarded
Restricted
- Quarantine
Quarantine
-