Commit a20bf50842fddd487aa96682f91b75216e95dbd3

Authored by graphlemon
1 parent 7674e4eb

graphlemon: auto-commit uncommitted changes for daef8910-e6a7-4060-babd-dd736e6f1a79

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&#39;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")
... ...