Files
AgriciDaniel 5c21c90bc0 fix(audit): grand-audit remediation across all 32 sub-skills + v1.11.0
A 17-agent Codex (gpt-5.5, xhigh) audit of the skill repo and brain vault
surfaced 672 findings; 11 file-disjoint Codex fix agents remediated them,
folding in best-practice ports from Gogh and the Fable-5 brain.

Security: SSRF guards (generate_hero redirect bypass, blog_preflight HEAD,
nlp_analyze, all URL-fetch sub-skills), XSS escaping (blog_render raw HTML +
attrs, audio embed, google_report, video srcdoc, Hugo unsafe), path/symlink
write confinement, API-key redaction in logs, agent tool least-privilege.

Delivery contract: deterministic strict gates, review nonce moved out of the
draft dir, broken images/links now block, first-failure halt, repair-only
iteration counting.

Currency (2026): FAQPage + E-E-A-T reframed to Google guidance, Google-Extended
corrected, GA Gemini image IDs and 3.1 TTS, MCP schema alignment, Ads v24.2.

Consistency: one 30/25/15/15/15 scoring rubric, fixed broken reference/template
paths, added missing scripts (discourse_research.py, sync_flow.py), Gogh
deterministic chart-SVG CLI, restored orchestrator Untrusted-Data Contract.

Packaging: v1.11.0 across plugin.json/pyproject/CITATION/README/CHANGELOG,
217->232 tests, installer defaults to AI-Marketing-Hub/claude-blog.

Verify: 232 tests pass, prose lint clean, claude plugin validate passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:45:30 +03:00

476 lines
16 KiB
Python

#!/usr/bin/env python3
"""Sync FLOW operational references from GitHub into the blog-flow skill.
Ports the seo-flow sync pattern with blog-specific changes:
- Targets skills/blog-flow/references/ (not seo-flow).
- Skips the local stage entirely. Bloggers do not need GBP/citation prompts.
- Injects a CC BY 4.0 license header at the top of every synced markdown file.
- Path-traversal guard rejects any output path that escapes the references
directory.
- Stdlib only. HTTPS only. Host-allowlisted to api.github.com. 5 MB cap.
- Anonymous-first: the script only authenticates if GITHUB_TOKEN is set in the
environment, or if a 403 is returned and `gh auth token` provides one.
"""
import argparse
import base64
import datetime
import hashlib
import json
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
API_ROOT = "https://api.github.com/repos/AgriciDaniel/flow/contents"
_ALLOWED_HOST = "api.github.com"
_SIZE_LIMIT = 5 * 1024 * 1024 # 5 MB
LICENSE_HEADER = (
"<!-- (c) Daniel Agrici, FLOW (https://github.com/AgriciDaniel/flow), "
"CC BY 4.0 -->"
)
# Blog-applicable stages only. The "local" stage from FLOW is intentionally
# skipped: those prompts target Google Business Profile and brick-and-mortar
# audits, not blog content.
PROMPT_STAGES = ["find", "leverage", "optimize", "win"]
STATIC_FILES = [
("docs/01-framework/flow-framework.md", "flow-framework.md"),
("docs/10-references/bibliography.md", "bibliography.md"),
]
LOCK_REL = pathlib.Path("skills") / "blog-flow" / "references" / "flow-prompts.lock"
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Refuse redirects so the host allowlist applies to every response."""
def http_error_301(self, req, fp, code, msg, headers):
return None
http_error_302 = http_error_301
http_error_303 = http_error_301
http_error_307 = http_error_301
http_error_308 = http_error_301
_NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirectHandler())
def _validate_github_url(url):
"""Abort if url is not HTTPS or does not target the expected GitHub API host."""
parsed = urllib.parse.urlparse(url)
if parsed.scheme != "https" or parsed.netloc != _ALLOWED_HOST:
raise ValueError(
f"Blocked request to unexpected host: {parsed.netloc!r} "
f"(scheme: {parsed.scheme!r})"
)
def script_root():
script_dir = os.path.dirname(os.path.abspath(__file__))
return pathlib.Path(script_dir).parent
def parse_args():
epilog = (
"Modes: no flags syncs all blog-applicable files to disk; --dry-run "
"reports changes without writing; --ref <sha> syncs from a specific "
"FLOW commit."
)
parser = argparse.ArgumentParser(
description="Sync FLOW references into skills/blog-flow/references/.",
epilog=epilog,
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Report changes without writing files.",
)
parser.add_argument(
"--ref",
metavar="SHA",
help="Pin fetches to a FLOW commit SHA.",
)
parser.add_argument(
"--allow-drift",
action="store_true",
help="Permit lockfile drift (default: refuse to write when synced "
"content hashes do not match flow-prompts.lock). Required when bumping "
"the FLOW reference; review the diff carefully before passing this flag.",
)
return parser.parse_args()
def _base_headers():
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
env_token = os.environ.get("GITHUB_TOKEN", "").strip()
if env_token:
headers["Authorization"] = f"Bearer {env_token}"
return headers
def _authed_headers():
"""Return authenticated headers via gh CLI, or base headers if unavailable."""
try:
result = subprocess.run(
["gh", "auth", "token"], capture_output=True, text=True
)
except FileNotFoundError:
return _base_headers()
if result.returncode != 0 or not result.stdout.strip():
return _base_headers()
token = result.stdout.strip()
headers = _base_headers()
headers["Authorization"] = f"Bearer {token}"
return headers
def content_url(path, ref):
return f"{API_ROOT}/{path}" + (f"?ref={ref}" if ref else "")
def api_get(path, ref, headers):
url = content_url(path, ref)
_validate_github_url(url)
request = urllib.request.Request(url, headers=headers)
try:
with _NO_REDIRECT_OPENER.open(request, timeout=15) as response:
final_url = response.geturl()
_validate_github_url(final_url)
data = response.read(_SIZE_LIMIT + 1)
if len(data) > _SIZE_LIMIT:
raise ValueError(
f"Response for {path!r} exceeds {_SIZE_LIMIT} bytes"
)
return json.loads(data)
except urllib.error.HTTPError as exc:
if exc.code == 403 and "Authorization" not in headers:
authed = _authed_headers()
if "Authorization" in authed:
return api_get(path, ref, authed)
raise
def fetch_file(path, ref, headers):
data = api_get(path, ref, headers)
content = data.get("content", "")
return base64.b64decode(content).decode("utf-8")
def list_markdown_files(path, ref, headers):
data = api_get(path, ref, headers)
files = [
(item["path"], item["name"])
for item in data
if item.get("type") == "file" and item.get("name", "").endswith(".md")
]
return sorted(files, key=lambda item: item[1].lower())
def sync_attribution_comment(today):
"""A second comment line that records the sync date.
Kept separate from LICENSE_HEADER so the dedupe check on LICENSE_HEADER
works idempotently across runs.
"""
return f"<!-- Synced from FLOW on {today} -->"
def inject_license_header(raw, today):
"""Prepend the CC BY 4.0 license header to raw, idempotently.
If the file already starts with the exact license header, do not duplicate.
Always appends a sync-date comment beneath the header for traceability.
"""
sync_line = sync_attribution_comment(today)
if raw.startswith(LICENSE_HEADER):
# Already licensed. Refresh only the sync-date line if present on line 2.
lines = raw.split("\n", 2)
if len(lines) >= 2 and lines[1].startswith("<!-- Synced from FLOW on"):
lines[1] = sync_line
return "\n".join(lines)
# No sync line yet. Insert one after the header.
return f"{LICENSE_HEADER}\n{sync_line}\n{raw[len(LICENSE_HEADER):].lstrip(chr(10))}"
return f"{LICENSE_HEADER}\n{sync_line}\n{raw}"
def frontmatter_value(lines, key):
if not lines or lines[0].strip() != "---":
return ""
needle = f"{key}:"
for line in lines[1:]:
stripped = line.strip()
if stripped == "---":
break
if stripped.lower().startswith(needle):
value = stripped[len(needle):].strip()
return value.strip("\"'")
return ""
def body_lines_after_frontmatter(lines):
if not lines or lines[0].strip() != "---":
return lines
for index, line in enumerate(lines[1:], 1):
if line.strip() == "---":
return lines[index + 1:]
return lines
def first_h1(lines):
for line in body_lines_after_frontmatter(lines):
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip()
return ""
def first_description(lines):
for line in body_lines_after_frontmatter(lines):
stripped = line.strip()
if stripped and not stripped.startswith("#"):
return stripped
return ""
def prompt_meta(stage, filename, raw):
lines = raw.splitlines()
return {
"stage": stage,
"filename": filename,
"title": frontmatter_value(lines, "title") or first_h1(lines),
"description": (
frontmatter_value(lines, "description") or first_description(lines)
),
}
def escape_cell(value):
return str(value).replace("|", "\\|").replace("\n", " ").strip()
def prompt_readme(rows):
lines = [
"# FLOW Prompt Index (Blog-Applicable)",
"",
"Local-SEO prompts are excluded by design. See claude-seo for those.",
"",
"| Stage | Filename | Title | Description |",
"|---|---|---|---|",
]
for row in rows:
lines.append(
"| {stage} | {filename} | {title} | {description} |".format(
stage=escape_cell(row["stage"]),
filename=escape_cell(row["filename"]),
title=escape_cell(row["title"]),
description=escape_cell(row["description"]),
)
)
return "\n".join(lines) + "\n"
def _sha256(content):
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def _atomic_write(path, content):
"""Write content atomically via a temp file in the same directory."""
dir_ = path.parent
fd, tmp = tempfile.mkstemp(dir=dir_, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(content)
shutil.move(tmp, path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _assert_inside_references(refs_root, candidate):
"""Path-traversal guard.
Every output target must resolve to a path inside refs_root. Abort with a
clear error if it does not, regardless of how the path was constructed.
"""
refs_resolved = refs_root.resolve()
candidate_resolved = candidate.resolve()
refs_str = str(refs_resolved)
cand_str = str(candidate_resolved)
if cand_str != refs_str and not cand_str.startswith(refs_str + os.sep):
raise ValueError(
f"Path traversal blocked: {candidate_resolved} is outside "
f"{refs_resolved}"
)
def record_write(root, refs_root, path, content, dry_run, changes):
# Path-traversal guard. Must run before any filesystem mutation.
_assert_inside_references(refs_root, path)
rel = path.relative_to(root).as_posix()
changes.setdefault("hashes", {})[rel] = _sha256(content)
if path.exists():
current = path.read_text(encoding="utf-8")
bucket = "unchanged" if current == content else "updated"
else:
bucket = "added"
changes[bucket].append(rel)
print(f"{bucket}: {rel}", file=sys.stderr)
if not dry_run and bucket != "unchanged":
path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(path, content)
def sync(args):
root = script_root()
refs = root / "skills" / "blog-flow" / "references"
today = datetime.date.today().isoformat()
headers = _base_headers()
changes = {"added": [], "updated": [], "unchanged": [], "hashes": {}}
prompt_rows = []
# Phase 1: fetch and stage all content in memory. Compute would-be hashes
# without writing, so the drift check can fire BEFORE any filesystem
# mutation (audit VULN-018).
pending = [] # list of (target_path, content) tuples
for source, target in STATIC_FILES:
print(f"fetch: {source}", file=sys.stderr)
raw = fetch_file(source, args.ref, headers)
content = inject_license_header(raw, today)
target_path = refs / target
pending.append((target_path, content))
rel = target_path.relative_to(root).as_posix()
changes["hashes"][rel] = _sha256(content)
for stage in PROMPT_STAGES:
source_dir = f"docs/09-prompts/{stage}"
print(f"list: {source_dir}", file=sys.stderr)
for source, filename in list_markdown_files(source_dir, args.ref, headers):
print(f"fetch: {source}", file=sys.stderr)
raw = fetch_file(source, args.ref, headers)
prompt_rows.append(prompt_meta(stage, filename, raw))
target_path = refs / "prompts" / stage / filename
content = inject_license_header(raw, today)
pending.append((target_path, content))
rel = target_path.relative_to(root).as_posix()
changes["hashes"][rel] = _sha256(content)
readme_path = refs / "prompts" / "README.md"
readme_content = inject_license_header(prompt_readme(prompt_rows), today)
pending.append((readme_path, readme_content))
readme_rel = readme_path.relative_to(root).as_posix()
changes["hashes"][readme_rel] = _sha256(readme_content)
# Phase 2: compute lockfile drift against the staged hashes (pre-write).
lock_path = root / LOCK_REL
drift_lines = []
if lock_path.exists():
old_lock = lock_path.read_text(encoding="utf-8")
old_hashes = {}
for line in old_lock.splitlines():
if line and not line.startswith("#"):
parts = line.split(" ", 1)
if len(parts) == 2:
old_hashes[parts[1]] = parts[0]
for rel, sha in sorted(changes["hashes"].items()):
old_sha = old_hashes.get(rel)
if old_sha is None:
drift_lines.append(f"ADDED {rel}")
elif old_sha != sha:
drift_lines.append(f"CHANGED {rel}")
for rel in sorted(old_hashes):
if rel not in changes["hashes"]:
drift_lines.append(f"REMOVED {rel}")
# Phase 3: enforce drift policy BEFORE any writes happen.
if drift_lines and not args.dry_run and not args.allow_drift:
print(
"ERROR: Lockfile drift detected. The synced content hashes do not "
"match flow-prompts.lock.",
file=sys.stderr,
)
print(
"Review the upstream changes carefully before bumping the lockfile.",
file=sys.stderr,
)
print(
"If the changes are legitimate, re-run with --allow-drift.",
file=sys.stderr,
)
print("Drift report:", file=sys.stderr)
for line in drift_lines:
print(f" {line}", file=sys.stderr)
sys.exit(2)
elif drift_lines and args.allow_drift:
print(
"NOTE: Lockfile drift detected. Proceeding because --allow-drift "
"was explicitly passed.",
file=sys.stderr,
)
for line in drift_lines:
print(f" {line}", file=sys.stderr)
elif drift_lines and args.dry_run:
print(
"DRY-RUN: Lockfile drift detected (informational, no exit):",
file=sys.stderr,
)
for line in drift_lines:
print(f" {line}", file=sys.stderr)
elif lock_path.exists():
print(
"Lockfile: no drift (all hashes match baseline)",
file=sys.stderr,
)
# Phase 4: write all staged content to disk. The hash for each file was
# already recorded in Phase 1, so we drop record_write's hash-recording
# side effect by passing a throwaway dict for the hashes slot. We keep the
# added/updated/unchanged bookkeeping by reusing `changes`.
for target_path, content in pending:
record_write(root, refs, target_path, content, args.dry_run, changes)
# Phase 5: build and write the lockfile from the now-finalized hashes.
lock_lines = [
"# flow-prompts.lock. SHA-256 baseline for synced FLOW prompts.",
f"# Ref: {args.ref or 'HEAD'} | format: <sha256hex> <rel_path> "
"(sha256sum-compatible)",
"",
]
for rel in sorted(changes["hashes"]):
lock_lines.append(f"{changes['hashes'][rel]} {rel}")
lock_content = "\n".join(lock_lines) + "\n"
# Write lockfile (excluded from its own hashes tracking).
record_write(root, refs, lock_path, lock_content, args.dry_run, changes)
lock_rel = LOCK_REL.as_posix()
changes["hashes"].pop(lock_rel, None)
for bucket in ("added", "updated", "unchanged"):
try:
changes[bucket].remove(lock_rel)
except ValueError:
pass
return changes
if __name__ == "__main__":
print(json.dumps(sync(parse_args()), sort_keys=True))