diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index b16fa1a2..f779a398 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -148,6 +148,16 @@ SERVER_OWNED_FIELDS = ( @settings.router.put("") async def update_settings(body: AppSettings): + saved = await apply_settings_update(body) + return {"ok": True, "settings": saved.model_dump()} + + +async def apply_settings_update(body: AppSettings) -> AppSettings: + """Persist a full settings object with all the safety side effects: restore + server-owned fields, hand the wheel back from the free trial when a real + model is connected, reconcile 9router provider connections, and sync + analytics/identity. The PUT route and the agent settings tool both call this + so the write semantics can't drift between them. Returns the saved body.""" from backend.apps.service.client import sync as _sync old = load_settings() @@ -271,7 +281,7 @@ async def update_settings(body: AppSettings): any_keyed_added, )) - return {"ok": True, "settings": body.model_dump()} + return body class AppThemeOverridePayload(BaseModel): diff --git a/backend/apps/skill_registry/skill_registry.py b/backend/apps/skill_registry/skill_registry.py index ab97c409..946b65ef 100644 --- a/backend/apps/skill_registry/skill_registry.py +++ b/backend/apps/skill_registry/skill_registry.py @@ -365,6 +365,11 @@ async def resolve_community_skill(source: str, skill_id: str) -> dict: raise ValueError("SKILL.md could not be fetched") meta, _body = _parse_frontmatter(files["SKILL.md"]) + # Reuse the .swarm importer's content scan: flag files holding secret-shaped + # literals (the author's leaked key, or a sketchy skill) so the user sees it + # before installing from an unvetted repo. + from backend.apps.swarm.redact import find_secrets_in_files + secret_findings = find_secrets_in_files({rel: data.encode("utf-8", "ignore") for rel, data in files.items()}) return { "name": meta.get("name") or skill_id, "description": meta.get("description", ""), @@ -372,6 +377,7 @@ async def resolve_community_skill(source: str, skill_id: str) -> dict: "skill_id": skill_id, "files": files, "scripts": sorted(rel for rel in files if _is_script_path(rel)), + "secret_findings": secret_findings, } @@ -436,6 +442,7 @@ async def registry_install(req: _InstallRequest): "files": sorted(resolved["files"].keys()), "scripts": resolved["scripts"], "has_scripts": bool(resolved["scripts"]), + "secret_findings": resolved.get("secret_findings", []), } if not req.confirm: return {"installed": False, "disclosure": disclosure} diff --git a/backend/main.py b/backend/main.py index 68aad3bc..25270e3d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -766,7 +766,7 @@ async def settings_meta(action: str, request: Request): from backend.apps.settings.store import load_settings from backend.apps.settings.models import AppSettings from backend.apps.settings.redaction import redact_settings - from backend.apps.settings.settings import SERVER_OWNED_FIELDS, update_settings + from backend.apps.settings.settings import SERVER_OWNED_FIELDS, apply_settings_update from backend.apps.agents.session_credential import ( PoweringCredential, resolve_powering_credential, write_would_suicide, ) @@ -786,7 +786,7 @@ async def settings_meta(action: str, request: Request): valid_fields = set(AppSettings.model_fields.keys()) outcomes: dict[str, dict] = {} - # Serialize the read-modify-write: SettingsWrite goes through update_settings, + # Serialize the read-modify-write: SettingsWrite goes through apply_settings_update, # which awaits (so two autonomous agents would interleave and clobber each # other's fields while BOTH got an "applied" result). The lock makes agent # writes serial so the last load always sees the prior write. (Agent vs the @@ -828,7 +828,7 @@ async def settings_meta(action: str, request: Request): new_body = AppSettings(**merged) if staged and new_body is not None: try: - await update_settings(new_body) + await apply_settings_update(new_body) for f in staged: outcomes[f] = {"status": "applied"} except Exception as e: diff --git a/backend/tests/test_skill_registry_community.py b/backend/tests/test_skill_registry_community.py index 3ca1c713..945f17b0 100644 --- a/backend/tests/test_skill_registry_community.py +++ b/backend/tests/test_skill_registry_community.py @@ -47,6 +47,19 @@ def test_missing_skill_raises(): _select_skill_paths([{"type": "blob", "path": "a/SKILL.md"}], "nonexistent") +def test_install_disclosure_flags_secret_shaped_files(): + # The scan we wire into the install disclosure (reused from the .swarm importer) + # must flag a community skill shipping credentials, and leave clean files alone. + from backend.apps.swarm.redact import find_secrets_in_files + files = { + "SKILL.md": b"Renders PDFs. No secrets.", + "config.py": b'API_KEY = "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH"', + } + hits = find_secrets_in_files(files) + assert "config.py" in hits + assert "SKILL.md" not in hits + + def test_script_classification(): assert _is_script_path("run.sh") assert _is_script_path("helper.py") diff --git a/frontend/src/app/pages/Skills/CommunitySkillsDialog.tsx b/frontend/src/app/pages/Skills/CommunitySkillsDialog.tsx index 8cf0ad1a..748674fd 100644 --- a/frontend/src/app/pages/Skills/CommunitySkillsDialog.tsx +++ b/frontend/src/app/pages/Skills/CommunitySkillsDialog.tsx @@ -177,6 +177,18 @@ const CommunitySkillsDialog: React.FC = ({ open, onClose, onInstalled }) {selected.source} + {/* The real risk for an agent platform: SKILL.md is injected as instructions the + agent follows, with its full tool surface. Say that plainly, not just "scripts". */} + } sx={{ fontSize: '0.78rem', py: 0 }}> + This is an unvetted community skill. Its SKILL.md becomes instructions your agent will follow, and it can use your agent's tools (files, browser, settings). Only install from a source you trust, read it below first. + + + {disclosure.secret_findings.length > 0 && ( + } sx={{ fontSize: '0.78rem', py: 0 }}> + {disclosure.secret_findings.length} file{disclosure.secret_findings.length === 1 ? '' : 's'} contain secret-shaped text ({disclosure.secret_findings.slice(0, 3).join(', ')}{disclosure.secret_findings.length > 3 ? '…' : ''}). A trustworthy skill shouldn't ship credentials; treat this as a red flag. + + )} + {disclosure.has_scripts && ( } sx={{ fontSize: '0.78rem', py: 0 }}> Includes {disclosure.scripts.length} script file{disclosure.scripts.length === 1 ? '' : 's'} that can run code when an agent uses this skill. Installing only writes the files; nothing runs until an agent does, and that still goes through normal command approval. diff --git a/frontend/src/shared/state/skillRegistrySlice.ts b/frontend/src/shared/state/skillRegistrySlice.ts index 795ca563..b40b05be 100644 --- a/frontend/src/shared/state/skillRegistrySlice.ts +++ b/frontend/src/shared/state/skillRegistrySlice.ts @@ -149,6 +149,7 @@ export interface InstallDisclosure { files: string[]; scripts: string[]; has_scripts: boolean; + secret_findings: string[]; } export async function searchCommunitySkills(q: string): Promise {