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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

<p align="center"><strong>English</strong> | <a href="README.zh-CN.md">简体中文</a></p>

<p align="center"><sub>Built on <a href="https://github.com/HKUDS/nanobot">nanobot</a> · A demo version has been submitted to the <strong>Gemma 4 Good Hackathon</strong> (Kaggle, May 2026)</sub></p>
<p align="center"><sub>Built on <a href="https://github.com/HKUDS/nanobot">nanobot</a> · Submitted to the <strong>Gemma 4 Good Hackathon</strong> (Kaggle, May 2026)</sub></p>

CloakBot adds a **local privacy pipeline** between your session and any remote LLM. Before a message is sent upstream, a multi-agent system powered by trusted local model served through vLLM/Ollama runs two local JSON-only detectors: one for general sensitive entities and one for sensitive numeric or temporal values. Matched spans are rewritten into typed, reversible placeholders and stored in a session-scoped Vault. For math task turns, the remote LLM is asked for structure only while the real arithmetic happens locally with the original values from the Vault.

Expand Down Expand Up @@ -269,7 +269,7 @@ cloakbot/
└── start_vllm.sh Start vLLM server
```

Session-level placeholder mappings are persisted as JSON under `~/.cloakbot/sanitizer_maps/`, so the Vault can reuse the same placeholder mapping across turns in the same session. CloakBot now supports **multi-turn conversation privacy** by carrying forward placeholder mappings across turns while still restoring user-visible outputs locally. Computable placeholders also store normalized values for later local math execution.
Session-level placeholder mappings are persisted as JSON under `~/.cloakbot/workspace/privacy_vault/maps/`, so the Vault can reuse the same placeholder mapping across turns in the same session. CloakBot now supports **multi-turn conversation privacy** by carrying forward placeholder mappings across turns while still restoring user-visible outputs locally. Computable placeholders also store normalized values for later local math execution.

---

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ cloakbot/
└── start_vllm.sh 启动 vLLM 服务
```

会话级占位符映射会以 JSON 形式存到 `~/.cloakbot/sanitizer_maps/`,同一会话跨轮可复用。CloakBot 现在已支持**多轮会话隐私保护**:占位符映射可跨轮延续,同时对用户展示仍在本地恢复。可计算占位符还会保存规范化数值,用于后续本地数学执行。
会话级占位符映射会以 JSON 形式存到 `~/.cloakbot/workspace/privacy_vault/maps/`,同一会话跨轮可复用。CloakBot 现在已支持**多轮会话隐私保护**:占位符映射可跨轮延续,同时对用户展示仍在本地恢复。可计算占位符还会保存规范化数值,用于后续本地数学执行。

---

Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ chmod 600 ~/.cloakbot/config.json
The Vault holds plaintext `token ↔ raw value` mappings for the current session. Protect it accordingly:

```bash
chmod 700 ~/.cloakbot/vault/
chmod 700 ~/.cloakbot/workspace/privacy_vault/
```

Do not log vault contents, sync vault files to cloud storage, or leave stale vaults from old sessions on disk.
Expand Down Expand Up @@ -74,7 +74,7 @@ Requires `bwrap` (`apt install bubblewrap`). Not available on macOS or Windows.
If you suspect a key or session has been compromised:

1. Revoke all remote/local LLM API keys and channel bot tokens immediately.
2. Delete session Vault files under `~/.cloakbot/vault/`.
2. Delete session Vault files under `~/.cloakbot/workspace/privacy_vault/`.
3. Review logs for unauthorized access attempts.
4. Update to the latest release.
5. Report to maintainers via the channels above.
Expand Down
2 changes: 1 addition & 1 deletion cloakbot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
cloakbot - A lightweight AI agent framework
"""

__version__ = "0.1.5"
__version__ = "0.1.8"
__logo__ = "🥷"

from cloakbot.cloakbot import Cloakbot, RunResult
Expand Down
6 changes: 2 additions & 4 deletions cloakbot/agent/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@
from pathlib import Path
from typing import Any

from cloakbot.utils.helpers import current_time_str

from cloakbot.agent.memory import MemoryStore
from cloakbot.utils.prompt_templates import render_template
from cloakbot.agent.skills import SkillsLoader
from cloakbot.utils.helpers import build_assistant_message, detect_image_mime
from cloakbot.utils.helpers import build_assistant_message, current_time_str, detect_image_mime
from cloakbot.utils.prompt_templates import render_template


class ContextBuilder:
Expand Down
73 changes: 28 additions & 45 deletions cloakbot/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
from cloakbot.agent.context import ContextBuilder
from cloakbot.agent.hook import AgentHook, AgentHookContext, CompositeHook
from cloakbot.agent.memory import Consolidator, Dream
from cloakbot.agent.runner import AgentRunSpec, AgentRunner
from cloakbot.agent.runner import AgentRunner, AgentRunSpec
from cloakbot.agent.skills import BUILTIN_SKILLS_DIR
from cloakbot.agent.subagent import SubagentManager
from cloakbot.agent.tools.cron import CronTool
from cloakbot.agent.skills import BUILTIN_SKILLS_DIR
from cloakbot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from cloakbot.agent.tools.message import MessageTool
from cloakbot.agent.tools.registry import ToolRegistry
Expand All @@ -27,12 +27,13 @@
from cloakbot.agent.tools.spawn import SpawnTool
from cloakbot.agent.tools.web import WebFetchTool, WebSearchTool
from cloakbot.bus.events import InboundMessage, OutboundMessage
from cloakbot.command import CommandContext, CommandRouter, register_builtin_commands
from cloakbot.bus.queue import MessageBus
from cloakbot.command import CommandContext, CommandRouter, register_builtin_commands
from cloakbot.config.schema import AgentDefaults
from cloakbot.providers.base import LLMProvider
from cloakbot.privacy import Intent, post_llm_hook, pre_llm_hook
from cloakbot.privacy.transparency.report import build_session_privacy_snapshot
from cloakbot.privacy.webui import WEBUI_PRIVACY_METADATA_KEY, build_webui_privacy_payload
from cloakbot.privacy.webui.history import append_webui_privacy_payload
from cloakbot.providers.base import LLMProvider
from cloakbot.session.manager import Session, SessionManager
from cloakbot.utils.helpers import image_placeholder_text, truncate_text
from cloakbot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
Expand All @@ -42,18 +43,6 @@
from cloakbot.cron.service import CronService


def _build_webui_privacy_turn_payload(turn_ctx) -> dict[str, Any]:
return {
"turnId": turn_ctx.turn_id,
"intent": turn_ctx.intent.value,
"remotePrompt": turn_ctx.sanitized_input,
"localComputations": [
computation.model_dump(mode="json")
for computation in turn_ctx.local_computations
],
}


class _LoopHook(AgentHook):
"""Core hook for the main loop."""

Expand Down Expand Up @@ -226,6 +215,9 @@ def __init__(
self._last_usage: dict[str, int] = {}
self._extra_hooks: list[AgentHook] = hooks or []

from cloakbot.privacy.core.state.vault import set_vault_workspace

set_vault_workspace(workspace)
self.context = ContextBuilder(workspace, timezone=timezone)
self.sessions = session_manager or SessionManager(workspace)
self.tools = ToolRegistry()
Expand Down Expand Up @@ -471,21 +463,15 @@ async def on_stream(delta: str) -> None:
async def on_stream_end(
*,
resuming: bool = False,
privacy: dict[str, Any] | None = None,
privacy_annotations: list[dict[str, Any]] | None = None,
privacy_turn: dict[str, Any] | None = None,
webui_privacy: dict[str, Any] | None = None,
) -> None:
nonlocal stream_segment
meta = dict(msg.metadata or {})
meta["_stream_end"] = True
meta["_resuming"] = resuming
meta["_stream_id"] = _current_stream_id()
if privacy is not None:
meta["privacy"] = privacy
if privacy_annotations is not None:
meta["privacyAnnotations"] = privacy_annotations
if privacy_turn is not None:
meta["privacyTurn"] = privacy_turn
if webui_privacy is not None:
meta[WEBUI_PRIVACY_METADATA_KEY] = webui_privacy
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="",
Expand Down Expand Up @@ -667,21 +653,15 @@ async def _buffered_stream_end(*, resuming: bool = False) -> None:
for i in range(0, len(finalized), chunk):
await on_stream(finalized[i : i + chunk])
if on_stream_end is not None:
privacy = None
privacy_annotations = None
privacy_turn = None
webui_privacy = None
if msg.channel == "webui":
privacy = build_session_privacy_snapshot(session_key).model_dump(mode="json")
privacy_annotations = [
annotation.model_dump(mode="json")
for annotation in turn_ctx.display_output_annotations
]
privacy_turn = _build_webui_privacy_turn_payload(turn_ctx)
webui_privacy = build_webui_privacy_payload(
session_key,
turn_ctx,
).model_dump(mode="json", by_alias=True)
await on_stream_end(
resuming=resuming,
privacy=privacy,
privacy_annotations=privacy_annotations,
privacy_turn=privacy_turn,
webui_privacy=webui_privacy,
)

effective_stream: Callable[[str], Awaitable[None]] | None = _buffered_stream
Expand All @@ -706,6 +686,14 @@ async def _buffered_stream_end(*, resuming: bool = False) -> None:
turn_ctx.tool_calls_made = len(tools_used)
final_content = await _finalize_response_text(final_content)

webui_privacy_payload = None
if msg.channel == "webui":
webui_privacy_payload = build_webui_privacy_payload(
session_key,
turn_ctx,
)
append_webui_privacy_payload(self.workspace, session_key, webui_privacy_payload)

self._save_turn(session, all_msgs, 1 + len(history))
self._clear_runtime_checkpoint(session)
self.sessions.save(session)
Expand All @@ -718,13 +706,8 @@ async def _buffered_stream_end(*, resuming: bool = False) -> None:
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)

meta = dict(msg.metadata or {})
if msg.channel == "webui":
meta["privacy"] = build_session_privacy_snapshot(session_key).model_dump(mode="json")
meta["privacyAnnotations"] = [
annotation.model_dump(mode="json")
for annotation in turn_ctx.display_output_annotations
]
meta["privacyTurn"] = _build_webui_privacy_turn_payload(turn_ctx)
if webui_privacy_payload is not None:
meta[WEBUI_PRIVACY_METADATA_KEY] = webui_privacy_payload.model_dump(mode="json", by_alias=True)
if on_stream is not None:
meta["_streamed"] = True
return OutboundMessage(
Expand Down
14 changes: 9 additions & 5 deletions cloakbot/agent/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@

from loguru import logger

from cloakbot.utils.prompt_templates import render_template
from cloakbot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain, strip_think

from cloakbot.agent.runner import AgentRunSpec, AgentRunner
from cloakbot.agent.runner import AgentRunner, AgentRunSpec
from cloakbot.agent.tools.registry import ToolRegistry
from cloakbot.utils.gitstore import GitStore
from cloakbot.utils.helpers import (
ensure_dir,
estimate_message_tokens,
estimate_prompt_tokens_chain,
strip_think,
)
from cloakbot.utils.prompt_templates import render_template

if TYPE_CHECKING:
from cloakbot.providers.base import LLMProvider
Expand Down Expand Up @@ -286,7 +290,7 @@ def _read_last_entry(self) -> dict[str, Any] | None:
read_size = min(size, 4096)
f.seek(size - read_size)
data = f.read().decode("utf-8")
lines = [l for l in data.split("\n") if l.strip()]
lines = [line for line in data.split("\n") if line.strip()]
if not lines:
return None
return json.loads(lines[-1])
Expand Down
15 changes: 7 additions & 8 deletions cloakbot/agent/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from loguru import logger

from cloakbot.agent.hook import AgentHook, AgentHookContext
from cloakbot.utils.prompt_templates import render_template
from cloakbot.agent.tools.registry import ToolRegistry
from cloakbot.providers.base import LLMProvider, ToolCallRequest
from cloakbot.utils.helpers import (
Expand All @@ -21,6 +20,7 @@
maybe_persist_tool_result,
truncate_text,
)
from cloakbot.utils.prompt_templates import render_template
from cloakbot.utils.runtime import (
EMPTY_FINAL_RESPONSE_MESSAGE,
build_finalization_retry_message,
Expand Down Expand Up @@ -380,7 +380,7 @@ async def _run_tool(
tool_call: ToolCallRequest,
external_lookup_counts: dict[str, int],
) -> tuple[Any, dict[str, str], BaseException | None]:
_HINT = "\n\n[Analyze the error above and try a different approach.]"
hint = "\n\n[Analyze the error above and try a different approach.]"
lookup_error = repeated_external_lookup_error(
tool_call.name,
tool_call.arguments,
Expand All @@ -393,8 +393,8 @@ async def _run_tool(
"detail": "repeated external lookup blocked",
}
if spec.fail_on_tool_error:
return lookup_error + _HINT, event, RuntimeError(lookup_error)
return lookup_error + _HINT, event, None
return lookup_error + hint, event, RuntimeError(lookup_error)
return lookup_error + hint, event, None
prepare_call = getattr(spec.tools, "prepare_call", None)
tool, params, prep_error = None, tool_call.arguments, None
if callable(prepare_call):
Expand All @@ -410,7 +410,7 @@ async def _run_tool(
"status": "error",
"detail": prep_error.split(": ", 1)[-1][:120],
}
return prep_error + _HINT, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None
return prep_error + hint, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None
try:
if tool is not None:
result = await tool.execute(**params)
Expand All @@ -435,8 +435,8 @@ async def _run_tool(
"detail": result.replace("\n", " ").strip()[:120],
}
if spec.fail_on_tool_error:
return result + _HINT, event, RuntimeError(result)
return result + _HINT, event, None
return result + hint, event, RuntimeError(result)
return result + hint, event, None

detail = "" if result is None else str(result)
detail = detail.replace("\n", " ").strip()
Expand Down Expand Up @@ -602,4 +602,3 @@ def _partition_tool_batches(
if current:
batches.append(current)
return batches

4 changes: 2 additions & 2 deletions cloakbot/agent/subagent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
from loguru import logger

from cloakbot.agent.hook import AgentHook, AgentHookContext
from cloakbot.utils.prompt_templates import render_template
from cloakbot.agent.runner import AgentRunSpec, AgentRunner
from cloakbot.agent.runner import AgentRunner, AgentRunSpec
from cloakbot.agent.skills import BUILTIN_SKILLS_DIR
from cloakbot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from cloakbot.agent.tools.registry import ToolRegistry
Expand All @@ -21,6 +20,7 @@
from cloakbot.bus.queue import MessageBus
from cloakbot.config.schema import ExecToolConfig, WebToolsConfig
from cloakbot.providers.base import LLMProvider
from cloakbot.utils.prompt_templates import render_template


class _SubagentHook(AgentHook):
Expand Down
7 changes: 6 additions & 1 deletion cloakbot/agent/tools/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
from typing import Any

from cloakbot.agent.tools.base import Tool, tool_parameters
from cloakbot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
from cloakbot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
StringSchema,
tool_parameters_schema,
)
from cloakbot.cron.service import CronService
from cloakbot.cron.types import CronJob, CronJobState, CronSchedule

Expand Down
15 changes: 10 additions & 5 deletions cloakbot/agent/tools/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
from typing import Any

from cloakbot.agent.tools.base import Tool, tool_parameters
from cloakbot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
from cloakbot.utils.helpers import build_image_content_blocks, detect_image_mime
from cloakbot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
StringSchema,
tool_parameters_schema,
)
from cloakbot.config.paths import get_media_dir
from cloakbot.utils.helpers import build_image_content_blocks, detect_image_mime


def _resolve_path(
Expand All @@ -24,7 +29,7 @@ def _resolve_path(
resolved = p.resolve()
if allowed_dir:
media_path = get_media_dir().resolve()
all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or [])
all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or [])
if not any(_is_under(resolved, d) for d in all_dirs):
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
return resolved
Expand Down Expand Up @@ -209,13 +214,13 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
old_lines = old_text.splitlines()
if not old_lines:
return None, 0
stripped_old = [l.strip() for l in old_lines]
stripped_old = [line.strip() for line in old_lines]
content_lines = content.splitlines()

candidates = []
for i in range(len(content_lines) - len(stripped_old) + 1):
window = content_lines[i : i + len(stripped_old)]
if [l.strip() for l in window] == stripped_old:
if [line.strip() for line in window] == stripped_old:
candidates.append("\n".join(window))

if candidates:
Expand Down
2 changes: 1 addition & 1 deletion cloakbot/agent/tools/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ async def execute(
) -> str:
from cloakbot.utils.helpers import strip_think
content = strip_think(content)

channel = channel or self._default_channel
chat_id = chat_id or self._default_chat_id
# Only inherit default message_id when targeting the same channel+chat.
Expand Down
Loading