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 | 1738 | <div class="breadcrumb"><a href="#/skills">技能库</a> / ${escapeHtml(skillId)}</div> |
| 1739 | 1739 | <h1>${escapeHtml(skillId)} 变更审计</h1> |
| 1740 | 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 | 1744 | <div id="skill_audit_list"><div class="hint">加载中…</div></div> |
| 1742 | 1745 | </div> |
| 1743 | 1746 | `; |
| ... | ... | @@ -1746,6 +1749,24 @@ function renderSkillAuditLog(skillId) { |
| 1746 | 1749 | function mountSkillAuditLog(skillId) { |
| 1747 | 1750 | const listEl = document.getElementById("skill_audit_list"); |
| 1748 | 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 | 1771 | async function refresh() { |
| 1751 | 1772 | const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/audit"); | ... | ... |
src/backend_server.py
| ... | ... | @@ -195,6 +195,10 @@ class _Handler(BaseHTTPRequestHandler): |
| 195 | 195 | skill_id = parsed.path[len(_SKILLS_PREFIX):-len("/audit")] |
| 196 | 196 | self._handle_skill_audit_log(skill_id) |
| 197 | 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 | 202 | if parsed.path == "/api/deployments": |
| 199 | 203 | self._handle_deployment_list() |
| 200 | 204 | return |
| ... | ... | @@ -704,6 +708,22 @@ class _Handler(BaseHTTPRequestHandler): |
| 704 | 708 | else: |
| 705 | 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 | 727 | def _handle_skill_diff(self, query_string): |
| 708 | 728 | params = urllib.parse.parse_qs(query_string) |
| 709 | 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 | 10 | import os |
| 11 | 11 | import subprocess |
| 12 | 12 | |
| 13 | +from src import skill_update | |
| 14 | + | |
| 13 | 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 | 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 | 118 | def get_diff(before, after, repo_path=None): |
| 56 | 119 | """c_skill_diff: `git diff <before> <after>` -- skill_update.py's |
| 57 | 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 | 1521 | status, body = self._post("/api/skills/skill-1/rollback", {}) |
| 1522 | 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 | 1542 | class SettingsRouteTest(unittest.TestCase): |
| 1526 | 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 | 5 | Run: python3 -m unittest src.test_skill_query -v |
| 6 | 6 | """ |
| 7 | 7 | |
| 8 | +import os | |
| 8 | 9 | import subprocess |
| 9 | 10 | import tempfile |
| 10 | 11 | import unittest |
| ... | ... | @@ -87,6 +88,85 @@ class GetSkillAuditLogTest(SkillQueryTestBase): |
| 87 | 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 | 170 | class GetDiffTest(SkillQueryTestBase): |
| 91 | 171 | def test_diff_between_two_commits_shows_content_change(self): |
| 92 | 172 | before_sha = skill_update._write_and_commit(self.tmp.name, "skills/s1/SKILL.md", "v1 content\n", "seed") | ... | ... |