[eric] skills/settings-agent: scan community SKILL.md+files for secrets & reframe install risk (agent-instructions, not just scripts); extract apply_settings_update so the tool stops calling the PUT route as a fn

This commit is contained in:
ciregenz
2026-06-19 04:03:58 -07:00
parent 72433fced0
commit f866a3717c
6 changed files with 47 additions and 4 deletions
+11 -1
View File
@@ -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):
@@ -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}
+3 -3
View File
@@ -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:
@@ -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")
@@ -177,6 +177,18 @@ const CommunitySkillsDialog: React.FC<Props> = ({ open, onClose, onInstalled })
{selected.source} <OpenInNewIcon sx={{ fontSize: 13 }} />
</Box>
{/* 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". */}
<Alert severity="info" icon={<WarningAmberIcon fontSize="small" />} 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.
</Alert>
{disclosure.secret_findings.length > 0 && (
<Alert severity="error" icon={<WarningAmberIcon fontSize="small" />} 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.
</Alert>
)}
{disclosure.has_scripts && (
<Alert severity="warning" icon={<WarningAmberIcon fontSize="small" />} 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.
@@ -149,6 +149,7 @@ export interface InstallDisclosure {
files: string[];
scripts: string[];
has_scripts: boolean;
secret_findings: string[];
}
export async function searchCommunitySkills(q: string): Promise<CommunitySkill[]> {