Commit a20bf50842fddd487aa96682f91b75216e95dbd3
1 parent
7674e4eb
graphlemon: auto-commit uncommitted changes for daef8910-e6a7-4060-babd-dd736e6f1a79
Showing
5 changed files
with
201 additions
and
0 deletions
frontend/app.js
| @@ -1738,6 +1738,9 @@ function renderSkillAuditLog(skillId) { | @@ -1738,6 +1738,9 @@ function renderSkillAuditLog(skillId) { | ||
| 1738 | <div class="breadcrumb"><a href="#/skills">技能库</a> / ${escapeHtml(skillId)}</div> | 1738 | <div class="breadcrumb"><a href="#/skills">技能库</a> / ${escapeHtml(skillId)}</div> |
| 1739 | <h1>${escapeHtml(skillId)} 变更审计</h1> | 1739 | <h1>${escapeHtml(skillId)} 变更审计</h1> |
| 1740 | <div id="skill_audit_error" class="error-banner" style="display:none;"></div> | 1740 | <div id="skill_audit_error" class="error-banner" style="display:none;"></div> |
| 1741 | + <h2>Skill 内容</h2> | ||
| 1742 | + <div id="skill_content"><div class="hint">加载中…</div></div> | ||
| 1743 | + <h2>迭代记录</h2> | ||
| 1741 | <div id="skill_audit_list"><div class="hint">加载中…</div></div> | 1744 | <div id="skill_audit_list"><div class="hint">加载中…</div></div> |
| 1742 | </div> | 1745 | </div> |
| 1743 | `; | 1746 | `; |
| @@ -1746,6 +1749,24 @@ function renderSkillAuditLog(skillId) { | @@ -1746,6 +1749,24 @@ function renderSkillAuditLog(skillId) { | ||
| 1746 | function mountSkillAuditLog(skillId) { | 1749 | function mountSkillAuditLog(skillId) { |
| 1747 | const listEl = document.getElementById("skill_audit_list"); | 1750 | const listEl = document.getElementById("skill_audit_list"); |
| 1748 | const errorEl = document.getElementById("skill_audit_error"); | 1751 | const errorEl = document.getElementById("skill_audit_error"); |
| 1752 | + const contentEl = document.getElementById("skill_content"); | ||
| 1753 | + | ||
| 1754 | + // Independent of the iteration-history panel below (a skill can have real | ||
| 1755 | + // content with zero skill_iteration rows -- that's the normal post- | ||
| 1756 | + // migration state) -- fetched and rendered separately, side by side, not | ||
| 1757 | + // as an either/or with refresh()'s own list. | ||
| 1758 | + fetch("/api/skills/" + encodeURIComponent(skillId) + "/content").then(async (resp) => { | ||
| 1759 | + if (resp.status !== 200) { | ||
| 1760 | + const body = await resp.json(); | ||
| 1761 | + contentEl.innerHTML = '<div class="hint">' + (body.error === "CONTENT_MISSING" ? "该 Skill 的真实文件尚未迁移" : "该 Skill 不存在") + "</div>"; | ||
| 1762 | + return; | ||
| 1763 | + } | ||
| 1764 | + const skill = await resp.json(); | ||
| 1765 | + const supportHtml = skill.support_files.length | ||
| 1766 | + ? '<div class="hint">支持文件:' + skill.support_files.map(escapeHtml).join("、") + "</div>" | ||
| 1767 | + : ""; | ||
| 1768 | + contentEl.innerHTML = supportHtml + '<pre class="code">' + escapeHtml(skill.content) + "</pre>"; | ||
| 1769 | + }); | ||
| 1749 | 1770 | ||
| 1750 | async function refresh() { | 1771 | async function refresh() { |
| 1751 | const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/audit"); | 1772 | const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/audit"); |
src/backend_server.py
| @@ -195,6 +195,10 @@ class _Handler(BaseHTTPRequestHandler): | @@ -195,6 +195,10 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 195 | skill_id = parsed.path[len(_SKILLS_PREFIX):-len("/audit")] | 195 | skill_id = parsed.path[len(_SKILLS_PREFIX):-len("/audit")] |
| 196 | self._handle_skill_audit_log(skill_id) | 196 | self._handle_skill_audit_log(skill_id) |
| 197 | return | 197 | return |
| 198 | + if parsed.path.startswith(_SKILLS_PREFIX) and parsed.path.endswith("/content"): | ||
| 199 | + skill_id = parsed.path[len(_SKILLS_PREFIX):-len("/content")] | ||
| 200 | + self._handle_skill_content(skill_id) | ||
| 201 | + return | ||
| 198 | if parsed.path == "/api/deployments": | 202 | if parsed.path == "/api/deployments": |
| 199 | self._handle_deployment_list() | 203 | self._handle_deployment_list() |
| 200 | return | 204 | return |
| @@ -704,6 +708,22 @@ class _Handler(BaseHTTPRequestHandler): | @@ -704,6 +708,22 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 704 | else: | 708 | else: |
| 705 | self._send_json(200, result) | 709 | self._send_json(200, result) |
| 706 | 710 | ||
| 711 | + def _handle_skill_content(self, skill_id): | ||
| 712 | + """c_skill_content (bug-fix Unit, no MVCP operation_id -- same | ||
| 713 | + "unowned gap" precedent as c_artifact_get before U-025). Real | ||
| 714 | + SKILL.md content off disk, alongside (not instead of) the existing | ||
| 715 | + /audit iteration history -- v_skill_audit_log's page renders both | ||
| 716 | + independently.""" | ||
| 717 | + try: | ||
| 718 | + with self.server.conn_lock: | ||
| 719 | + result = skill_query.get_skill_content(self.server.conn, skill_id, repo_path=self.server.skill_repo_path) | ||
| 720 | + except skill_query.SkillNotFoundError: | ||
| 721 | + self._send_json(404, {"error": "NOT_FOUND"}) | ||
| 722 | + except skill_query.SkillContentMissingError: | ||
| 723 | + self._send_json(404, {"error": "CONTENT_MISSING"}) | ||
| 724 | + else: | ||
| 725 | + self._send_json(200, result) | ||
| 726 | + | ||
| 707 | def _handle_skill_diff(self, query_string): | 727 | def _handle_skill_diff(self, query_string): |
| 708 | params = urllib.parse.parse_qs(query_string) | 728 | params = urllib.parse.parse_qs(query_string) |
| 709 | before = params.get("before", [None])[0] | 729 | before = params.get("before", [None])[0] |
src/skill_query.py
| @@ -10,6 +10,8 @@ rather than inventing a second way to locate it. | @@ -10,6 +10,8 @@ rather than inventing a second way to locate it. | ||
| 10 | import os | 10 | import os |
| 11 | import subprocess | 11 | import subprocess |
| 12 | 12 | ||
| 13 | +from src import skill_update | ||
| 14 | + | ||
| 13 | _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | 15 | _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 14 | 16 | ||
| 15 | 17 | ||
| @@ -52,6 +54,67 @@ def get_skill_audit_log(conn, skill_id): | @@ -52,6 +54,67 @@ def get_skill_audit_log(conn, skill_id): | ||
| 52 | ] | 54 | ] |
| 53 | 55 | ||
| 54 | 56 | ||
| 57 | +class SkillContentMissingError(Exception): | ||
| 58 | + """CONTENT_MISSING -- get_skill_content on a skill row whose real | ||
| 59 | + SKILL.md file isn't present on disk. Distinct from SkillNotFoundError | ||
| 60 | + (no such skill row at all): a skill row can legitimately exist with its | ||
| 61 | + real file not-yet-migrated, and callers (v_skill_audit_log's new "Skill | ||
| 62 | + 内容" panel) need to tell the two apart to show the right message.""" | ||
| 63 | + | ||
| 64 | + | ||
| 65 | +def get_skill_content(conn, skill_id, repo_path=None): | ||
| 66 | + """v_skill_audit_log's new "Skill 内容" panel (bug fix: this page used to | ||
| 67 | + show only skill_iteration history -- correct on its own terms, but | ||
| 68 | + silent about the real SKILL.md content a skill migration already wrote | ||
| 69 | + to disk under skills/<skill_id>/). Independent of get_skill_audit_log: | ||
| 70 | + a skill can have real content with zero iterations, which is exactly | ||
| 71 | + the post-migration, pre-any-self-iteration state today. | ||
| 72 | + | ||
| 73 | + skill_id is user-controlled (comes straight off the URL), but it is | ||
| 74 | + never spliced into a filesystem path itself -- the lookup below is a | ||
| 75 | + parameterized query, and the actual on-disk path comes from the | ||
| 76 | + trusted `skill.file_path` DB column. Still applies two-layer | ||
| 77 | + path-traversal protection on that DB value: reuse | ||
| 78 | + skill_update._skill_file_path() for the same SKILL.md-suffix | ||
| 79 | + resolution c_skill_update/c_skill_rollback already rely on, then | ||
| 80 | + realpath+containment-check the result against the repo's skills/ root | ||
| 81 | + before ever opening it -- defense in depth against a future schema/data | ||
| 82 | + bug turning this read path into an arbitrary-file-read, not a fix for | ||
| 83 | + an exploit that's live today. | ||
| 84 | + """ | ||
| 85 | + row = conn.execute("SELECT file_path FROM skill WHERE skill_id = ?", (skill_id,)).fetchone() | ||
| 86 | + if row is None: | ||
| 87 | + raise SkillNotFoundError(skill_id) | ||
| 88 | + | ||
| 89 | + repo_root = repo_path or _REPO_ROOT | ||
| 90 | + skills_root = os.path.realpath(os.path.join(repo_root, "skills")) | ||
| 91 | + rel_path = skill_update._skill_file_path(row[0]) | ||
| 92 | + abs_path = os.path.realpath(os.path.join(repo_root, rel_path)) | ||
| 93 | + if os.path.commonpath([abs_path, skills_root]) != skills_root: | ||
| 94 | + # Layer 1: DB-stored file_path resolves outside skills/ entirely. | ||
| 95 | + raise SkillContentMissingError(skill_id) | ||
| 96 | + | ||
| 97 | + try: | ||
| 98 | + with open(abs_path, "r", encoding="utf-8", errors="replace") as f: | ||
| 99 | + content = f.read() | ||
| 100 | + except OSError: | ||
| 101 | + # Layer 2: within skills/ but the file itself isn't there (not-yet- | ||
| 102 | + # migrated skill, or a stale DB row). | ||
| 103 | + raise SkillContentMissingError(skill_id) | ||
| 104 | + | ||
| 105 | + skill_dir = os.path.dirname(abs_path) | ||
| 106 | + support_files = [] | ||
| 107 | + for dirpath, _dirnames, filenames in os.walk(skill_dir): | ||
| 108 | + for name in filenames: | ||
| 109 | + full_path = os.path.realpath(os.path.join(dirpath, name)) | ||
| 110 | + if full_path == abs_path: | ||
| 111 | + continue | ||
| 112 | + support_files.append(os.path.relpath(full_path, skill_dir)) | ||
| 113 | + support_files.sort() | ||
| 114 | + | ||
| 115 | + return {"skill_id": skill_id, "content": content, "support_files": support_files} | ||
| 116 | + | ||
| 117 | + | ||
| 55 | def get_diff(before, after, repo_path=None): | 118 | def get_diff(before, after, repo_path=None): |
| 56 | """c_skill_diff: `git diff <before> <after>` -- skill_update.py's | 119 | """c_skill_diff: `git diff <before> <after>` -- skill_update.py's |
| 57 | _write_and_commit makes every iteration an atomic single-file commit, | 120 | _write_and_commit makes every iteration an atomic single-file commit, |
src/test_backend_server.py
| @@ -1521,6 +1521,23 @@ class SkillRouteTest(unittest.TestCase): | @@ -1521,6 +1521,23 @@ class SkillRouteTest(unittest.TestCase): | ||
| 1521 | status, body = self._post("/api/skills/skill-1/rollback", {}) | 1521 | status, body = self._post("/api/skills/skill-1/rollback", {}) |
| 1522 | self.assertEqual(status, 400) | 1522 | self.assertEqual(status, 400) |
| 1523 | 1523 | ||
| 1524 | + def test_content_returns_real_file_content(self): | ||
| 1525 | + skill_update._write_and_commit(self.tmp.name, "skills/skill-1/SKILL.md", "real content\n", "seed skill") | ||
| 1526 | + status, body = self._get("/api/skills/skill-1/content") | ||
| 1527 | + self.assertEqual(status, 200) | ||
| 1528 | + self.assertEqual(body["content"], "real content\n") | ||
| 1529 | + self.assertEqual(body["support_files"], []) | ||
| 1530 | + | ||
| 1531 | + def test_content_nonexistent_skill_returns_404(self): | ||
| 1532 | + status, body = self._get("/api/skills/no-such-skill/content") | ||
| 1533 | + self.assertEqual(status, 404) | ||
| 1534 | + self.assertEqual(body["error"], "NOT_FOUND") | ||
| 1535 | + | ||
| 1536 | + def test_content_missing_file_returns_404_content_missing(self): | ||
| 1537 | + status, body = self._get("/api/skills/skill-1/content") | ||
| 1538 | + self.assertEqual(status, 404) | ||
| 1539 | + self.assertEqual(body["error"], "CONTENT_MISSING") | ||
| 1540 | + | ||
| 1524 | 1541 | ||
| 1525 | class SettingsRouteTest(unittest.TestCase): | 1542 | class SettingsRouteTest(unittest.TestCase): |
| 1526 | """U-030: c_concurrency_config_update / c_budget_config_update / | 1543 | """U-030: c_concurrency_config_update / c_budget_config_update / |
src/test_skill_query.py
| @@ -5,6 +5,7 @@ graphlemon's own -- same reasoning as test_skill_update.py). | @@ -5,6 +5,7 @@ graphlemon's own -- same reasoning as test_skill_update.py). | ||
| 5 | Run: python3 -m unittest src.test_skill_query -v | 5 | Run: python3 -m unittest src.test_skill_query -v |
| 6 | """ | 6 | """ |
| 7 | 7 | ||
| 8 | +import os | ||
| 8 | import subprocess | 9 | import subprocess |
| 9 | import tempfile | 10 | import tempfile |
| 10 | import unittest | 11 | import unittest |
| @@ -87,6 +88,85 @@ class GetSkillAuditLogTest(SkillQueryTestBase): | @@ -87,6 +88,85 @@ class GetSkillAuditLogTest(SkillQueryTestBase): | ||
| 87 | self.assertEqual(result[0]["change_summary"], "fix A") | 88 | self.assertEqual(result[0]["change_summary"], "fix A") |
| 88 | 89 | ||
| 89 | 90 | ||
| 91 | +class GetSkillContentTest(SkillQueryTestBase): | ||
| 92 | + """Bug fix: the skill detail page showed only skill_iteration history | ||
| 93 | + and nothing for the real migrated SKILL.md content sitting on disk. | ||
| 94 | + Uses the same tmp git repo as the rest of this file's tests -- these | ||
| 95 | + file reads don't need git at all, but keeping repo_path consistent with | ||
| 96 | + the other tests here avoids inventing a second fixture shape.""" | ||
| 97 | + | ||
| 98 | + def _write_skill_file(self, skill_id, content, extra_files=None): | ||
| 99 | + skill_dir = os.path.join(self.tmp.name, "skills", skill_id) | ||
| 100 | + os.makedirs(skill_dir, exist_ok=True) | ||
| 101 | + with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as f: | ||
| 102 | + f.write(content) | ||
| 103 | + for rel in extra_files or []: | ||
| 104 | + full = os.path.join(skill_dir, rel) | ||
| 105 | + os.makedirs(os.path.dirname(full), exist_ok=True) | ||
| 106 | + with open(full, "w", encoding="utf-8") as f: | ||
| 107 | + f.write("support file content") | ||
| 108 | + | ||
| 109 | + def test_nonexistent_skill_raises_not_found(self): | ||
| 110 | + with self.assertRaises(skill_query.SkillNotFoundError): | ||
| 111 | + skill_query.get_skill_content(self.conn, "no-such-skill", repo_path=self.tmp.name) | ||
| 112 | + | ||
| 113 | + def test_returns_real_file_content(self): | ||
| 114 | + self._insert_skill("s1", "coder") | ||
| 115 | + self._write_skill_file("s1", "real skill content\n") | ||
| 116 | + result = skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name) | ||
| 117 | + self.assertEqual(result["skill_id"], "s1") | ||
| 118 | + self.assertEqual(result["content"], "real skill content\n") | ||
| 119 | + self.assertEqual(result["support_files"], []) | ||
| 120 | + | ||
| 121 | + def test_lists_support_files_alongside_content(self): | ||
| 122 | + self._insert_skill("s1", "coder") | ||
| 123 | + self._write_skill_file( | ||
| 124 | + "s1", "content\n", | ||
| 125 | + extra_files=["references/notes.md", "templates/t.md", "scripts/run.sh"], | ||
| 126 | + ) | ||
| 127 | + result = skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name) | ||
| 128 | + self.assertEqual( | ||
| 129 | + set(result["support_files"]), | ||
| 130 | + {"references/notes.md", "templates/t.md", "scripts/run.sh"}, | ||
| 131 | + ) | ||
| 132 | + | ||
| 133 | + def test_row_exists_but_file_missing_raises_content_missing(self): | ||
| 134 | + self._insert_skill("s1", "coder") | ||
| 135 | + with self.assertRaises(skill_query.SkillContentMissingError): | ||
| 136 | + skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name) | ||
| 137 | + | ||
| 138 | + def test_file_path_already_ending_in_md_is_read_directly(self): | ||
| 139 | + self._insert_skill("s1", "coder") | ||
| 140 | + self.conn.execute("UPDATE skill SET file_path = ? WHERE skill_id = ?", ("skills/s1/SKILL.md", "s1")) | ||
| 141 | + self.conn.commit() | ||
| 142 | + self._write_skill_file("s1", "direct path content\n") | ||
| 143 | + result = skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name) | ||
| 144 | + self.assertEqual(result["content"], "direct path content\n") | ||
| 145 | + | ||
| 146 | + def test_path_traversal_in_stored_file_path_is_rejected(self): | ||
| 147 | + """Defense-in-depth: even if skill.file_path were somehow poisoned | ||
| 148 | + with a traversal sequence (not reachable via the HTTP skill_id | ||
| 149 | + param today, since that only drives a parameterized WHERE clause), | ||
| 150 | + the resolved path must stay confined to skills/.""" | ||
| 151 | + self._insert_skill("s1", "coder") | ||
| 152 | + self.conn.execute( | ||
| 153 | + "UPDATE skill SET file_path = ? WHERE skill_id = ?", ("../../../../etc/passwd", "s1"), | ||
| 154 | + ) | ||
| 155 | + self.conn.commit() | ||
| 156 | + with self.assertRaises(skill_query.SkillContentMissingError): | ||
| 157 | + skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name) | ||
| 158 | + | ||
| 159 | + def test_path_traversal_via_dotdot_segments_stays_confined(self): | ||
| 160 | + self._insert_skill("s1", "coder") | ||
| 161 | + self.conn.execute( | ||
| 162 | + "UPDATE skill SET file_path = ? WHERE skill_id = ?", | ||
| 163 | + ("skills/../../outside/SKILL.md", "s1"), | ||
| 164 | + ) | ||
| 165 | + self.conn.commit() | ||
| 166 | + with self.assertRaises(skill_query.SkillContentMissingError): | ||
| 167 | + skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name) | ||
| 168 | + | ||
| 169 | + | ||
| 90 | class GetDiffTest(SkillQueryTestBase): | 170 | class GetDiffTest(SkillQueryTestBase): |
| 91 | def test_diff_between_two_commits_shows_content_change(self): | 171 | def test_diff_between_two_commits_shows_content_change(self): |
| 92 | before_sha = skill_update._write_and_commit(self.tmp.name, "skills/s1/SKILL.md", "v1 content\n", "seed") | 172 | before_sha = skill_update._write_and_commit(self.tmp.name, "skills/s1/SKILL.md", "v1 content\n", "seed") |