diff --git a/docs/advanced/skills.mdx b/docs/advanced/skills.mdx
index 8d9ced96..ce624424 100644
--- a/docs/advanced/skills.mdx
+++ b/docs/advanced/skills.mdx
@@ -68,10 +68,10 @@ Framework-specific testing patterns.
Third-party service and platform security.
-| Skill | Coverage |
-| -------------------- | ---------------------------------- |
-| `supabase` | Supabase RLS bypasses, auth issues |
-| `firebase_firestore` | Firestore rules, Firebase auth |
+| Skill | Coverage |
+| ---------- | ------------------------------------------------------ |
+| `supabase` | Supabase RLS bypasses, auth issues |
+| `firebase` | Firebase Firestore, Storage rules, Auth, and Functions |
### Protocols
diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja
index 6af47c4e..5fc697d7 100644
--- a/strix/agents/prompts/system_prompt.jinja
+++ b/strix/agents/prompts/system_prompt.jinja
@@ -490,8 +490,10 @@ Default user: pentester (sudo available)
On-demand specialist skills. Spawn a specialist via `create_agent(skills=[...])`, or pull guidance inline for yourself via `load_skill(skills=[...])`. Anything wrapped in `` above is already loaded for you.
-{% for category, names in available_skills | dictsort -%}
-- {{ category }}: {{ names | join(', ') }}
+{% for category, skills in available_skills | dictsort -%}
+{% for skill in skills -%}
+- {{ category }}/{{ skill.name }}{% if skill.description %}: {{ skill.description }}{% endif %}
+{% endfor -%}
{% endfor -%}
{% endif %}
diff --git a/strix/skills/__init__.py b/strix/skills/__init__.py
index 31ac3059..0adf3d99 100644
--- a/strix/skills/__init__.py
+++ b/strix/skills/__init__.py
@@ -4,6 +4,9 @@ import threading
from collections import Counter
from collections.abc import Iterator
from pathlib import Path
+from typing import TypeGuard
+
+import yaml
from strix.telemetry import posthog, scarf
from strix.utils.resource_paths import get_strix_resource_path
@@ -11,12 +14,17 @@ from strix.utils.resource_paths import get_strix_resource_path
logger = logging.getLogger(__name__)
-_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
+_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(?P
.*?)\n---\s*\n", re.DOTALL)
_INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination"})
_ROOT_SKILL_CATEGORY = "root"
_EXTRA_SKILL_DIRS: list[Path] = []
+_SKILL_METADATA_CACHE: dict[tuple[Path, int, int], dict[str, str]] = {}
+
+
+def _is_frontmatter_mapping(value: object) -> TypeGuard[dict[object, object]]:
+ return isinstance(value, dict)
def register_skill_dir(path: str | Path) -> None:
@@ -109,13 +117,18 @@ def _get_ambiguous_skill_names() -> set[str]:
return {name for name, count in counts.items() if count > 1}
-def _qualified_skill_files(skill_name: str) -> list[Path]:
+def _qualified_skill_file_for_name(skill_name: str) -> Path | None:
category, _, name = skill_name.partition("/")
for skills_dir in skill_search_dirs():
candidate = _qualified_skill_file(skills_dir, category, name)
if candidate is not None:
- return [candidate]
- return []
+ return candidate
+ return None
+
+
+def _qualified_skill_files(skill_name: str) -> list[Path]:
+ candidate = _qualified_skill_file_for_name(skill_name)
+ return [candidate] if candidate is not None else []
def _bare_skill_files(skill_name: str) -> list[Path]:
@@ -145,10 +158,59 @@ def _bare_skill_files(skill_name: str) -> list[Path]:
return candidates
-def get_available_skills() -> dict[str, list[str]]:
- grouped: dict[str, list[str]] = {}
+def _parse_skill_content(content: str, source: Path | None = None) -> tuple[dict[str, str], str]:
+ """Parse skill frontmatter once and return metadata plus markdown body."""
+ frontmatter = _FRONTMATTER_PATTERN.match(content)
+ if frontmatter is None:
+ return {}, content.lstrip()
+
+ try:
+ parsed: object = yaml.safe_load(frontmatter.group("body"))
+ except yaml.YAMLError as error:
+ logger.warning("Failed to parse skill frontmatter %s: %s", source or "", error)
+ parsed = None
+ if not _is_frontmatter_mapping(parsed):
+ logger.warning("Skill frontmatter is not a mapping: %s", source or "")
+ return {}, content[frontmatter.end() :].lstrip()
+
+ metadata = {str(key): "" if value is None else str(value) for key, value in parsed.items()}
+ return metadata, content[frontmatter.end() :].lstrip()
+
+
+def _read_skill_metadata(file_path: Path) -> dict[str, str]:
+ try:
+ stat = file_path.stat()
+ except OSError:
+ logger.warning("Skill file disappeared while reading metadata: %s", file_path)
+ return {}
+ cache_key = (file_path, stat.st_mtime_ns, stat.st_size)
+ cached = _SKILL_METADATA_CACHE.get(cache_key)
+ if cached is not None:
+ return cached
+ try:
+ content = file_path.read_text(encoding="utf-8")
+ except (OSError, ValueError):
+ logger.warning("Failed to read skill metadata: %s", file_path)
+ return {}
+ metadata, _ = _parse_skill_content(content, file_path)
+ _SKILL_METADATA_CACHE[cache_key] = metadata
+ return metadata
+
+
+def get_available_skills() -> dict[str, list[dict[str, str]]]:
+ grouped: dict[str, list[dict[str, str]]] = {}
for category, name in _iter_user_skill_files():
- grouped.setdefault(category, []).append(name)
+ file_path = _qualified_skill_file_for_name(f"{category}/{name}")
+ if file_path is None:
+ logger.warning(
+ "Skill disappeared while gathering available skills: %s/%s",
+ category,
+ name,
+ )
+ continue
+ metadata = _read_skill_metadata(file_path)
+ description = " ".join(metadata.get("description", "").split())
+ grouped.setdefault(category, []).append({"name": name, "description": description})
return grouped
@@ -228,7 +290,8 @@ def load_skills(skill_names: list[str]) -> dict[str, str]:
continue
var_name = skill_name.split("/")[-1]
- skill_content[var_name] = _FRONTMATTER_PATTERN.sub("", content).lstrip()
+ _, skill_body = _parse_skill_content(content, file_path)
+ skill_content[var_name] = skill_body
logger.debug("Loaded skill: %s -> %s", skill_name, var_name)
_track_skill_loaded(var_name, file_path)
diff --git a/strix/skills/cloud/gcp.md b/strix/skills/cloud/gcp.md
index a6f28293..2c721594 100644
--- a/strix/skills/cloud/gcp.md
+++ b/strix/skills/cloud/gcp.md
@@ -16,7 +16,7 @@ GCP misconfigurations expose project data, service account keys, and lateral mov
**Storage & Data**
- Cloud Storage (GCS) buckets and objects
-- BigQuery datasets, Cloud SQL instances, Firestore (see `firebase_firestore` skill)
+- BigQuery datasets, Cloud SQL instances, Firestore (see `firebase` skill)
- Secret Manager, Cloud KMS keys
**Compute**
diff --git a/strix/skills/technologies/firebase_firestore.md b/strix/skills/technologies/firebase.md
similarity index 64%
rename from strix/skills/technologies/firebase_firestore.md
rename to strix/skills/technologies/firebase.md
index a0c48728..34104033 100644
--- a/strix/skills/technologies/firebase_firestore.md
+++ b/strix/skills/technologies/firebase.md
@@ -1,9 +1,9 @@
---
-name: firebase-firestore
-description: Firebase/Firestore security testing covering security rules, Cloud Functions, and client-side trust issues
+name: firebase
+description: Firebase security testing covering Firestore, Storage rules, Realtime Database, Auth, Functions, and client-side trust issues
---
-# Firebase / Firestore
+# Firebase
Security testing for Firebase applications. Focus on Firestore/Realtime Database rules, Cloud Storage exposure, callable/onRequest Functions trusting client input, and incorrect ID token validation.
@@ -30,7 +30,17 @@ Security testing for Firebase applications. Focus on Firestore/Realtime Database
**Endpoints**
- Firestore REST: `https://firestore.googleapis.com/v1/projects//databases/(default)/documents/`
- Realtime DB: `https://.firebaseio.com/.json`
-- Storage REST: `https://storage.googleapis.com/storage/v1/b/`
+- GCS JSON API: `https://storage.googleapis.com/storage/v1/b/`
+- Firebase Storage rules API: `https://firebasestorage.googleapis.com/v0/b//o`
+
+Cloud Storage has two front doors with different authorization engines:
+
+| Front door | Authorization engine |
+| --- | --- |
+| `storage.googleapis.com//