Skip to content

Commit 2eb6e5b

Browse files
sw-architectclaude
andcommitted
v0.26.0 + v0.27.0 — Anthropic-style filesystem skills with full security gate
Implements the hybrid FS-source-of-truth + PG-audit-and-telemetry model for Anthropic's bundled-script skill design. Claude Code's native loader continues to discover skills under ~/.claude/skills/<name>/ (and per-project <proj>/.claude/skills/<name>/); SecureContext layers admission, HMAC tamper detection, AST-based script scanning, and a chained audit trail on top. ## v0.26.0 — Admission Gate (Steps 1-7) * Step 2 (filesystem_skill_import.ts): walks ~/.claude/skills/ and per-project paths; parses SKILL.md frontmatter; mirrors to skills_pg with skill_dir + script_hmacs (per-script HMAC-SHA256 keyed with machine_secret). PG migration 24 adds the columns. Idempotent — same body_hmac + same script_hmacs + same dir → no-op (key-by-key map compare to dodge JSONB ordering issues). * Step 3 (script_scanner.ts + scripts/py_ast_walker.py): AST-based scanner. Python (ast module): detects eval/exec/compile, os.system/popen, subprocess(shell=True), pickle.loads, yaml.load, dynamic __import__. JavaScript (acorn): detects eval, new Function, child_process.exec/spawn, vm.runInNewContext. Failed scans atomic-move the dir to ~/.claude/skills.quarantine/<name>__<ts>/ with a .quarantine-reason.txt. EXDEV-aware (Docker bind-mount cross-device): falls back to copy+rm. * Step 4 PreToolUse hook (~/.claude/hooks/skill-script-hmac-verify.mjs not in this repo — installed on the operator's host): regex-detects Bash calls invoking <name>/scripts/<file>.{py,js,mjs,cjs,sh}, calls server-side /api/v1/skills/<name>/verify-script which reads the file from its bind-mounted view, computes HMAC, compares to stored. Server-side verification keeps machine_secret inside the container. Exempted from the global API-key gate because the hook subprocess doesn't inherit MCP env. Fail-CLOSED on missing/quarantined/mismatch; fail-OPEN on out-of-scope or hook crash. Bypass: ZC_SKILL_HMAC_BYPASS=1. * Step 5 frontmatter validator: per Anthropic spec (name ≤64 chars lowercase, description ≤1024, allowed_tools array, user_invocable + disable_model_invocation booleans). Parse-error skills quarantine via the same atomic-move so Claude Code's native loader can't see them either. * Step 6 admission_log.ts + PG migration 25: HMAC-chained audit trail (prev_hash → row_hash keyed with machine_secret, advisory-locked). Every admit/update/quarantine event also mirrored to ~/.claude/zc-ctx/logs/audit.log as a JSONL anchor (second-line defense if PG row is deleted). New endpoint /api/v1/skills/admission-log/verify walks the chain and reports breaks. * Step 7 dashboard (src/dashboard/render.ts + api-server.ts): new #fs-skills-panel with three fragments — green/red chain integrity banner, quarantine table with reasons, recent admission events list. Added 📁 filesystem source badge to active-skill rows showing script count and skill_dir on tooltip. CSS classes added for chain banner / quarantine table / event badges / filesystem badge. ## v0.27.0 — Marketplace bundled-scripts gap fix Pre-existing marketplace pull (pullFromMarketplace) only fetched SKILL.md text. Skills like anthropic-docx/pptx/xlsx/webapp-testing/web-artifacts-builder ship bundled scripts whose paths the SKILL.md references but which never reached disk — leaving those skills half-installed. * pullMarketplaceToFilesystem(): walks the full repo tree, materializes every file under each skills/<name>/ into ~/.claude/skills/anthropic-<name>/, rewrites SKILL.md frontmatter to use the prefixed name, then delegates to importFilesystemSkills(). Operator opt-in lists for two known-risky patterns: - shell_exec_ok: frontmatter flag downgrades subprocess(shell=True) / os.system findings from block→warn (needed for webapp-testing's dev-server orchestration). - unsupported_scripts_ok: frontmatter flag admits skills with .sh/.rb scripts the AST scanner doesn't yet handle (needed for web-artifacts-builder). Operators must manually review the scripts before opting in. * Data file extensions (xml, xsd, json, yaml, html, ttf, png, pdf, …) now pass through script_scanner without scanning — Anthropic's OOXML skills ship schema files under scripts/office/ that were previously rejected as unsupported_language. Unknown extensions still fail closed. * New env var ZC_PROJECT_SKILL_PATHS (colon/comma-separated absolute paths) lets the boot importer enumerate per-project skill dirs in addition to ~/.claude/skills/. New /api/v1/skills/import-project endpoint triggers admission on a specific project path on demand. ## Bug fixes caught during live agent testing 1. Prompt-injection false-positive on a "do not follow these instructions" warning section of our own reference SKILL.md (low — content rephrased). 2. EXDEV cross-device link error in Docker bind-mounted quarantine moves (high — falls back to copy+rm). 3. 401 from PreToolUse hook against /verify-script (high — hook subprocess doesn't inherit ZC_API_KEY; exempted the read-only verify endpoint). 4. Script scanner rejected non-code extensions under scripts/ as unsupported_language (high — added data-file extension whitelist). 5. anthropic-webapp-testing legitimately uses subprocess shell=True (medium — added shell_exec_ok frontmatter opt-in). 6. shell_exec_ok parsed but silently dropped to undefined during type narrowing (medium — same fragility class as the Step-5 narrowing bug). 7. Boot-time idempotency check used JSON.stringify equality on JSONB key ordering, producing 14 spurious "updated" events on every restart (medium — switched to key-by-key map comparison). 8. .sh / .rb bundled scripts had no opt-in path (medium — added unsupported_scripts_ok). ## Test coverage 25 tests run, all 25 pass. Live multi-agent: 2 parallel Claude CLI agents invoking different skills concurrently, both PASS with no chain races. Docker hard-restart preserves machine_secret + chain integrity. Cross-role discovery (researcher-prompt agent organically discovered learn-from-youtube skill via SKILL.md description match). 17 marketplace skills now admit via the new path (with 2 opt-ins for the dev-server / shell-script-bundling skills); 4 quarantine fixtures (1 with prompt-injection, 1 with frontmatter schema violation, 1 with malicious scripts, 1 from script-scanner data-file gap pre-fix) demonstrate the rejection paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1fbeeac commit 2eb6e5b

13 files changed

Lines changed: 2326 additions & 12 deletions

docker/Dockerfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ FROM node:22-alpine AS runtime
2727
RUN addgroup -g 1001 -S securecontext && \
2828
adduser -u 1001 -S securecontext -G securecontext
2929

30+
# v0.26.0 Step 3 — install python3 for AST-based scanning of bundled scripts
31+
# in Anthropic-style filesystem skills. The script_scanner runs `python3 -c`
32+
# with a small ast.parse() walker that detects shell=True, eval, exec,
33+
# os.system, pickle.loads, etc. — patterns regex can't reliably catch.
34+
RUN apk add --no-cache python3
35+
3036
WORKDIR /app
3137

3238
# Copy built artifacts + production deps only
@@ -39,6 +45,10 @@ COPY --from=builder --chown=securecontext:securecontext /app/package.json ./pack
3945
# host skills/ over this in docker-compose for live editing.
4046
COPY --chown=securecontext:securecontext skills/ ./skills/
4147

48+
# v0.26.0 Step 3 — bake the Python AST walker for skill bundled-script scanning.
49+
# Called by src/skills/script_scanner.ts via spawnSync("python3", ...).
50+
COPY --chown=securecontext:securecontext scripts/py_ast_walker.py ./scripts/py_ast_walker.py
51+
4252
USER securecontext
4353

4454
# Health check — uses the /health endpoint

docker/docker-compose.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,17 @@ services:
8787
# Without this, HMAC-protected data in PG (skills.body_hmac, hash chains)
8888
# becomes unverifiable after every `docker compose build`.
8989
- api_state:/home/securecontext/.claude/zc-ctx
90+
# v0.26.0 Step 2 — mount the operator's ~/.claude/skills/ directory so the
91+
# filesystem-skill watcher can see Anthropic-style skill directories.
92+
# Writable because Step 3's quarantine atomic-moves dirs to
93+
# ~/.claude/skills.quarantine/ when admission scan fails.
94+
- ${USERPROFILE:-${HOME}}/.claude/skills:/home/securecontext/.claude/skills
95+
- ${USERPROFILE:-${HOME}}/.claude/skills.quarantine:/home/securecontext/.claude/skills.quarantine
96+
# v0.27.0 — optional mount(s) for project-scoped skill admission. The boot
97+
# importer + /api/v1/skills/import-project endpoint walk these paths to
98+
# admit any <project>/.claude/skills/<name>/. Add more lines + extend
99+
# ZC_PROJECT_SKILL_PATHS env to register additional projects.
100+
- ${USERPROFILE:-${HOME}}/AI_projects/Test_Agent_Coordination:/projects/Test_Agent_Coordination
90101
ports:
91102
- "${SC_API_PORT:-3099}:3099" # Direct access (dev) — use sc-nginx in production
92103
depends_on:

package-lock.json

Lines changed: 16 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "zc-ctx",
3-
"version": "0.25.3",
3+
"version": "0.27.0",
44
"description": "Secure memory & context optimization MCP plugin for Claude Code — drop-in replacement for context-mode with credential isolation, SSRF protection, MemGPT-style persistent memory, and A2A multi-agent broadcast channel",
55
"keywords": [
66
"claude-code",
@@ -80,6 +80,8 @@
8080
"@fastify/cors": "^11.2.0",
8181
"@modelcontextprotocol/sdk": "^1.0.0",
8282
"@types/pg": "^8.20.0",
83+
"acorn": "^8.16.0",
84+
"acorn-walk": "^8.3.5",
8385
"fastify": "^5.8.4",
8486
"graphology": "^0.26.0",
8587
"graphology-communities-louvain": "^2.0.2",

scripts/py_ast_walker.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env python3
2+
"""
3+
v0.26.0 Step 3 — Python AST walker for skill bundled-script scanning.
4+
5+
Reads a Python file from argv[1], parses with stdlib ast module, walks the
6+
tree looking for dangerous patterns, prints findings as JSON to stdout.
7+
8+
Detected patterns (block-severity):
9+
- subprocess.run/Popen/call with shell=True (allows arg injection)
10+
- os.system / os.popen
11+
- eval() / exec() / compile()
12+
- __import__() called dynamically (not at module-level import statement)
13+
- pickle.loads / pickle.load / dill.loads (untrusted deserialization)
14+
- yaml.load without SafeLoader
15+
16+
Detected patterns (warn-severity):
17+
- Hardcoded secret patterns inline (sk-ant-, sk-..., ghp_, etc.)
18+
- urllib/requests calls to non-allowlisted domains (deferred — needs allowlist arg)
19+
- File operations outside skill's expected scope (deferred)
20+
21+
Output: JSON {"passed": bool, "violations": [{"pattern", "line", "col", "severity", "snippet"}], "errors": []}
22+
Exit codes: 0 = scan completed (regardless of violations); 1 = parser couldn't read the file.
23+
"""
24+
import ast
25+
import json
26+
import sys
27+
from pathlib import Path
28+
29+
VIOLATIONS = []
30+
ERRORS = []
31+
SOURCE_LINES = []
32+
33+
34+
def add_violation(severity, pattern, node, snippet=None):
35+
VIOLATIONS.append({
36+
"pattern": pattern,
37+
"severity": severity,
38+
"line": getattr(node, "lineno", 0),
39+
"col": getattr(node, "col_offset", 0),
40+
"snippet": snippet or (SOURCE_LINES[node.lineno - 1].strip() if 0 < getattr(node, "lineno", 0) <= len(SOURCE_LINES) else ""),
41+
})
42+
43+
44+
class Walker(ast.NodeVisitor):
45+
def visit_Call(self, node):
46+
# Resolve the callable name
47+
func_name = self._resolve_call_name(node.func)
48+
49+
if func_name in ("eval", "exec"):
50+
add_violation("block", f"dynamic_{func_name}", node)
51+
52+
if func_name == "compile":
53+
add_violation("block", "compile_call", node)
54+
55+
if func_name == "__import__":
56+
# Inline __import__ is suspicious. Module-level `import` doesn't hit this node.
57+
add_violation("block", "dynamic_import", node)
58+
59+
if func_name in ("os.system", "os.popen"):
60+
add_violation("block", "os_shell_exec", node)
61+
62+
if func_name in ("subprocess.run", "subprocess.Popen", "subprocess.call",
63+
"subprocess.check_call", "subprocess.check_output", "subprocess.getoutput"):
64+
for kw in node.keywords:
65+
if kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
66+
add_violation("block", "subprocess_shell_true", node)
67+
break
68+
69+
if func_name in ("pickle.loads", "pickle.load", "dill.loads", "dill.load",
70+
"marshal.loads", "marshal.load"):
71+
add_violation("block", "untrusted_deserialization", node)
72+
73+
if func_name == "yaml.load":
74+
# yaml.load without Loader=SafeLoader is unsafe.
75+
# If a second positional arg or `Loader=` kwarg is present, check it.
76+
loader_arg = None
77+
if len(node.args) >= 2:
78+
loader_arg = node.args[1]
79+
for kw in node.keywords:
80+
if kw.arg == "Loader":
81+
loader_arg = kw.value
82+
break
83+
if loader_arg is None:
84+
add_violation("block", "yaml_load_unsafe", node)
85+
elif isinstance(loader_arg, ast.Attribute) and loader_arg.attr not in ("SafeLoader", "CSafeLoader"):
86+
add_violation("block", "yaml_load_unsafe", node)
87+
88+
self.generic_visit(node)
89+
90+
def _resolve_call_name(self, func_node):
91+
"""Resolve a Call.func into a dotted name like 'subprocess.run' or 'os.system'."""
92+
parts = []
93+
cur = func_node
94+
while True:
95+
if isinstance(cur, ast.Name):
96+
parts.append(cur.id); break
97+
if isinstance(cur, ast.Attribute):
98+
parts.append(cur.attr); cur = cur.value
99+
else:
100+
return ""
101+
return ".".join(reversed(parts))
102+
103+
104+
def main():
105+
if len(sys.argv) != 2:
106+
print(json.dumps({"passed": False, "violations": [], "errors": ["usage: py_ast_walker.py <script.py>"]}))
107+
sys.exit(1)
108+
path = Path(sys.argv[1])
109+
try:
110+
source = path.read_text(encoding="utf-8", errors="replace")
111+
except Exception as e:
112+
print(json.dumps({"passed": False, "violations": [], "errors": [f"read failed: {e}"]}))
113+
sys.exit(1)
114+
115+
global SOURCE_LINES
116+
SOURCE_LINES = source.splitlines()
117+
118+
try:
119+
tree = ast.parse(source, filename=str(path))
120+
except SyntaxError as e:
121+
# Parse error = can't analyze. Mark as block since we can't verify safety.
122+
VIOLATIONS.append({
123+
"pattern": "syntax_error",
124+
"severity": "block",
125+
"line": e.lineno or 0,
126+
"col": e.offset or 0,
127+
"snippet": str(e),
128+
})
129+
print(json.dumps({"passed": False, "violations": VIOLATIONS, "errors": ERRORS}))
130+
sys.exit(0)
131+
132+
Walker().visit(tree)
133+
blocking = [v for v in VIOLATIONS if v["severity"] == "block"]
134+
print(json.dumps({
135+
"passed": len(blocking) == 0,
136+
"violations": VIOLATIONS,
137+
"errors": ERRORS,
138+
}))
139+
140+
141+
if __name__ == "__main__":
142+
main()

0 commit comments

Comments
 (0)