Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!--
Copyright (C) 2026 Intel Corporation
SPDX-License-Identifier: Apache-2.0
-->

### Added

- **`subject.distribution_digest` in the subject scorecard** — the `sha256:` digest a `.well-known/agent-skills/index.json` entry (agentskills PR #254) would carry for the subject: the bytes of `SKILL.md` when its directory holds nothing else (a directory ref or a `SKILL.md` file ref alike). A skill with resources is served as an archive, whose bytes a checkout cannot predict, so it gets `null` under either ref, and so does a ref that is not a local path. A client can match a published result to the skill it installed by this field. `ref_digest` is unchanged: a content digest of the tree, which no index carries.
6 changes: 6 additions & 0 deletions dokimi/dokimi/schemas/subject_scorecard.v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@
"null"
]
},
"distribution_digest": {
"type": [
"string",
"null"
]
},
"members": {
"type": "array"
}
Expand Down
34 changes: 34 additions & 0 deletions dokimi/dokimi/subjects/scorecard.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

import hashlib
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -76,11 +77,13 @@ def build_subject_scorecard(
"kind": descriptor.subject.kind,
"ref": descriptor.subject.ref,
"ref_digest": _digest_ref(descriptor.subject.ref, base=Path.cwd()),
"distribution_digest": distribution_digest(descriptor.subject.ref, base=Path.cwd()),
"members": [
{
"kind": member.kind,
"ref": member.ref,
"ref_digest": _digest_ref(member.ref, base=Path.cwd()),
"distribution_digest": distribution_digest(member.ref, base=Path.cwd()),
Comment thread
antonwolfy marked this conversation as resolved.
Resolved
}
for member in descriptor.subject.members
],
Expand Down Expand Up @@ -138,6 +141,37 @@ def _digest_values(values: tuple[str, ...]) -> str:
return "sha256:" + h.hexdigest()


def distribution_digest(ref: str | None, *, base: Path) -> str | None:
"""The digest a ``.well-known/agent-skills`` index would publish for *ref*, or None.

That index (agentskills PR #254) hashes the bytes it serves: one ``SKILL.md`` for a
``skill-md`` entry, the archive for an ``archive`` entry. Only the first can be derived
from a checkout. A ``SKILL.md`` is hashed as is when its directory holds nothing else,
whether the ref names the file or the directory. A skill whose directory holds resources
is served as an archive, whose bytes depend on how it was packed, so it gets None rather
than a digest no index carries, under either spelling of its ref. So does a ref that is
not a local path. ``ref_digest`` stays a content digest of the tree.
"""
if not ref:
return None
if re.match(r"[A-Za-z][A-Za-z0-9+.-]+:", ref) and not ref.startswith("local:"):
return None
path = Path(ref.removeprefix("local:"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Must-fix: Non-local references are interpreted as local paths rather than explicitly rejected. On POSIX, create base/context7:/org/lib/SKILL.md as the directory's only entry, then call distribution_digest("context7:/org/lib", base=base): this returns a SHA-256 digest instead of None. Descriptor validation does not reject this reference, so both subject and member scorecards can incorrectly identify remote content using unrelated local bytes. Reject non-local reference schemes before filesystem lookup (while preserving Windows drive paths), and add a collision regression test; the current remote-reference assertion only covers a nonexistent path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answered, no new code. The scheme guard from de58607 stays in a5c5500, and the member test checks a context7: ref returns None.
Full suite at a5c5500: 12025 passed.

Comment thread
antonwolfy marked this conversation as resolved.
Resolved
if not path.is_absolute():
path = base / path
if path.is_dir():
skill_dir = path
elif path.name == "SKILL.md" and path.is_file():
skill_dir = path.parent
else:
return None
skill = skill_dir / "SKILL.md"
# Any other entry makes it an archive; one stray file is enough, so stop at the second.
if [p.name for _, p in zip(range(2), skill_dir.iterdir(), strict=False)] != ["SKILL.md"] or not skill.is_file():
return None
return "sha256:" + hashlib.sha256(skill.read_bytes()).hexdigest()


def _digest_ref(ref: str | None, *, base: Path) -> str | None:
if not ref:
return None
Expand Down
85 changes: 85 additions & 0 deletions dokimi/tests/test_subjects_digest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Copyright (C) 2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0

"""subject.distribution_digest: the digest a .well-known/agent-skills index carries."""

from __future__ import annotations

import hashlib
import sys
from pathlib import Path

import pytest

from dokimi.subjects.scorecard import distribution_digest


def test_distribution_digest_matches_a_well_known_skill_md_entry(tmp_path: Path):
# agentskills PR #254: a skill-md index entry carries sha256 of the SKILL.md bytes it serves.
skill = tmp_path / "skill"
skill.mkdir()
(skill / "SKILL.md").write_bytes(b"---\r\nname: s\r\n---\r\nbody\r\n")
expected = "sha256:" + hashlib.sha256(b"---\r\nname: s\r\n---\r\nbody\r\n").hexdigest()

assert distribution_digest("skill", base=tmp_path) == expected
assert distribution_digest("local:skill/SKILL.md", base=tmp_path) == expected

# With resources the skill is served as an archive, whose bytes a checkout cannot predict.
(skill / "ref.md").write_text("more", encoding="utf-8")
assert distribution_digest("skill", base=tmp_path) is None
assert distribution_digest("skill/SKILL.md", base=tmp_path) is None # the same skill, by its file ref
# A ref that is not a local path has no served bytes to hash.
assert distribution_digest("context7:/org/lib", base=tmp_path) is None
assert distribution_digest(None, base=tmp_path) is None
# Only SKILL.md is served as a skill-md entry; another file has no index digest.
(tmp_path / "notes.md").write_text("x", encoding="utf-8")
assert distribution_digest("notes.md", base=tmp_path) is None


@pytest.mark.skipif(sys.platform == "win32", reason="a colon is invalid in a Windows directory name")
def test_a_ref_with_a_scheme_gets_no_digest_even_when_a_directory_collides(tmp_path: Path):
colliding = tmp_path / "context7:" / "org" / "lib"
colliding.mkdir(parents=True)
Comment on lines +41 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · medium
Must-fix: this fixture creates a directory named context7:, which is invalid on Windows. colliding.mkdir(parents=True) therefore raises before the scheme guard is tested, causing the test suite to fail on Windows even though the implementation preserves drive-letter paths. Mark this POSIX-only collision test with a Windows skip, or mock the filesystem lookup to test rejection without creating an invalid filename.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 08cd011: the colon-named collision test is skipped on win32, since a colon is not valid in a Windows directory name. The rest of the file still runs on every platform. Local: test_subjects_digest.py 4 passed; full suite 12025 passed, 8 skipped.

(colliding / "SKILL.md").write_bytes(b"body")

assert distribution_digest("context7:/org/lib", base=tmp_path) is None


def test_the_scorecard_carries_the_distribution_digest(tmp_path: Path, monkeypatch):
from dokimi.artifacts import validate_artifact
from dokimi.subjects import build_subject_scorecard, load_subject

(tmp_path / "skill").mkdir()
(tmp_path / "skill" / "SKILL.md").write_bytes(b"body")
(tmp_path / "subject.toml").write_text(
'[subject]\nid = "s"\nkind = "skill"\nref = "skill"\n\n'
'[suite]\nproducts = ["p"]\nquestions = ["q.json"]\n', encoding="utf-8")
monkeypatch.chdir(tmp_path)

scorecard = build_subject_scorecard(load_subject(tmp_path / "subject.toml"), awareness_runs=[], out_dir=tmp_path)

assert scorecard["subject"]["distribution_digest"] == "sha256:" + hashlib.sha256(b"body").hexdigest()
validate_artifact("subject_scorecard", scorecard)
Comment on lines +61 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · low
Nit: This descriptor has no members, so deleting the new per-member distribution_digest field from build_subject_scorecard would leave all distribution-digest tests passing. Schema validation would not catch it either: members has no item constraints. Add a bundle scorecard test with a standalone SKILL.md member and a resource-bearing or non-local member, asserting their digest and null values respectively.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a5c5500. The member test now uses standalone/ and bundled/ (ref.md) plus a context7 ref, and expects [sha256 of member body, None, None], so each member is checked on its own.
Full suite at a5c5500: 12025 passed.



def test_the_scorecard_carries_a_distribution_digest_per_member(tmp_path: Path, monkeypatch):
from dokimi.subjects import build_subject_scorecard, load_subject

(tmp_path / "standalone").mkdir()
(tmp_path / "standalone" / "SKILL.md").write_bytes(b"member body")
(tmp_path / "bundled").mkdir()
(tmp_path / "bundled" / "SKILL.md").write_bytes(b"bundled body")
(tmp_path / "bundled" / "ref.md").write_text("resource", encoding="utf-8")
(tmp_path / "subject.toml").write_text(
'[subject]\nid = "b"\nkind = "bundle"\n\n'
'[[subject.members]]\nkind = "skill"\nref = "standalone"\n\n'
'[[subject.members]]\nkind = "skill"\nref = "bundled/SKILL.md"\n\n'
'[[subject.members]]\nkind = "skill"\nref = "context7:/org/lib"\n\n'
'[suite]\nproducts = ["p"]\nquestions = ["q.json"]\n', encoding="utf-8")
monkeypatch.chdir(tmp_path)

scorecard = build_subject_scorecard(load_subject(tmp_path / "subject.toml"), awareness_runs=[], out_dir=tmp_path)

# The standalone member has a digest; the bundled one has resources, so neither its file nor its directory does.
digests = [member["distribution_digest"] for member in scorecard["subject"]["members"]]
assert digests == ["sha256:" + hashlib.sha256(b"member body").hexdigest(), None, None]
Loading