fix(skill-creator): preserve recorded token counts in benchmarks (#7033)

* fix(skill-creator): preserve recorded token counts in benchmarks

Read timing.json token measurements independently of grading duration and retain recorded zero values. Keep the existing character-count fallback for missing measurements and add benchmark/report regression coverage.

* fix(skillscan): distinguish token metrics from credentials

Recognize typed token counts, numeric benchmark summaries, and signed deltas so skill-creator documentation passes deterministic review. Preserve credential names, string values, duplicate-key detection, and cloud-token findings; add regression coverage and document the boundary.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
MsNBBc3R 2026-10-18 20:44:39 +08:00 committed by GitHub
parent 12e619c233
commit 3dbee8f4aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 243 additions and 5 deletions

View File

@ -1465,6 +1465,10 @@ Discovery follows operator-managed directory symlinks, but skips links back to a
Skill Markdown and bundled text resources use UTF-8. Skill-creator CLI and review utilities read and write text explicitly as UTF-8 so localized skills behave consistently across operating systems.
Skill-creator benchmark aggregation uses recorded `timing.json` token counts even
when `grading.json` already contains the run duration. Recorded zero counts are
preserved; output character counts remain a legacy proxy only when token data is missing.
`SKILL.md` YAML frontmatter must open and close with `---` on its own line (trailing spaces or tabs are allowed). A line such as `---suffix` is not a closing delimiter.
Custom skill history preserves Unicode line separators inside saved content and metadata, keeping those revisions readable for history inspection and rollback. Malformed JSON history records are still rejected.
@ -1626,6 +1630,8 @@ SkillScan treats HTTP/HTTPS scheme spellings and HTTP hostnames case-insensitive
loopback (`[::1]`) URLs as local. External IPv6 endpoints still trigger network findings,
and cloud-metadata hostname detection is case-insensitive.
SkillScan recognizes unquoted integer model-usage counters such as `total_tokens` and `input_tokens` in skill examples as measurements. Benchmark token statistics and signed deltas are also recognized; credential fields and other string values remain subject to secret detection.
For Python credential mappings, SkillScan checks literal values while treating ordinary dictionary keys as labels. A mapping such as `tokens = {"access_token": os.getenv("ACCESS_TOKEN")}` does not report a hardcoded credential. Keys matching a recognized cloud or API token format are still checked as embedded credentials.
SkillScan blocks embedded legacy and modern `sk-` API tokens, including OpenAI project, service-account, and admin keys and Anthropic keys, even in Bearer headers without a secret assignment. Token bodies may contain `-` and `_`; finding evidence is fully redacted. Placeholder examples do not suppress detection of later credentials in the same file. Scheduled-task notification summaries also redact these key formats before sending them to external IM platforms.

View File

@ -6,6 +6,8 @@
- **Diagnostic scope**: only `yaml.YAMLError` produces a management diagnostic. Missing frontmatter, non-mapping or invalid metadata, non-UTF-8 files and read errors (including permission denied) remain excluded; the UI and README state those limits. An empty diagnostic list is not a package-validity check. `tests/test_skill_load_diagnostics.py` pins these exclusions and permission-error redaction without changing runtime parsing.
- **History JSONL**: split records on LF, not Unicode text separators; preserve U+0085/U+2028/U+2029 in saved fields. Keep malformed JSON rejected. Tests: `tests/test_skill_history_jsonl.py`.
- **SkillScan usage counters**: the text assignment sweep recognizes unquoted nonnegative integers under the exact model-usage names `tokens`, `total_tokens`, `input_tokens`, `output_tokens`, `prompt_tokens`, `completion_tokens`, `cached_tokens`, and `reasoning_tokens`. Benchmark `tokens` also accepts a signed numeric delta or an object containing exactly four nonnegative finite numeric statistics (`mean`, `stddev`, `min`, `max`); JSON parsing is capped at 512 characters. Recover the complete key before applying the exception. Quoted unsigned counts, credential prefixes, arbitrary containers, and cloud-token detection remain active. Regressions live in `tests/test_skillscan_secret_assignment_formats.py`.
- **SkillScan API tokens**: `secret-cloud-token` accepts `-` and `_` in the `sk-` family, matching the character class used by PII redaction. Legacy, OpenAI project/service-account/admin, and Anthropic keys must trigger `CRITICAL` even in a Bearer header without a secret assignment. Skip placeholder matches until a real credential is found, retaining at most one cloud-token finding per file. Keep evidence fully redacted and preserve the minimum length and word boundaries; synthetic regressions live in `tests/test_skillscan_native.py`. Keep the mirrored `sk-` pattern in `app/scheduler/notification_text.py` aligned so scheduled-run summaries scrub the same key formats before IM delivery.
- **SkillScan shell pipes**: resolve `env` launchers through interpreter paths and `command`/`exec`/`sudo` wrappers, including value-taking sudo flags. Preserve direct-pipe coverage of common shell interpreter paths/names and real backslash-newline continuations; keep repeated regex alternatives disjoint so failed matches on literal backslash text cannot stall scanning. Tokenize only the current simple command, in place, under a shared per-file work budget; only its stdin redirections override the pipe. Keep quoted words distinct from operators. Parse GNU `env -S` escapes separately from shell syntax, with iterative expansion capped at 256 splits/65,536 characters. Unknown runtime expansions remain unresolved; never read the scanner environment. Limit exhaustion retains warnings and already collected critical findings. Tests: `test_skillscan_env_review_regressions.py`, `test_skillscan_env_launcher.py`, and the existing native/shell suites.

View File

@ -12,7 +12,9 @@ from __future__ import annotations
import ast
import io
import json
import logging
import math
import posixpath
import re
import stat
@ -126,6 +128,8 @@ _PLACEHOLDER_VALUES = {"", "x", "xx", "xxx", "xxxx", "changeme", "change-me", "e
# word (`tokenizer`, `secretive`) still stays quiet, so this is not a blanket match.
_SECRET_ASSIGNMENT_RE = re.compile(r"(?im)(?<![A-Za-z0-9])(token|password|passwd|api[_-]?key|secret|credential)s?\b[\"']?\s*[:=]\s*[\"']?([^\"'\s#]+)")
_SECRET_ASSIGNMENT_NAME_RE = re.compile(r"(?i)^(?:token|password|passwd|api[_-]?key|secret|credential)s?$")
# Model-usage counters share the credential suffix but hold integer measurements.
_TOKEN_COUNT_NAMES = {"tokens", "total_tokens", "input_tokens", "output_tokens", "prompt_tokens", "completion_tokens", "cached_tokens", "reasoning_tokens"}
_SECRET_TOKEN_PATTERNS = tuple(
re.compile(pattern)
for pattern in (
@ -400,11 +404,54 @@ def _scan_secret_assignments_by_text(rel_path: str, text: str) -> list[SecurityF
"""``name[:=]value`` sweep for line-oriented text, and for Python that will not parse."""
for match in _SECRET_ASSIGNMENT_RE.finditer(text):
value = match.group(2).strip()
if _is_token_usage_measurement(text, match):
continue
if not _looks_like_placeholder(value):
return [_finding_from_match("secret-env-assignment", rel_path, text, match)]
return []
def _is_token_usage_measurement(text: str, match: re.Match[str]) -> bool:
"""Recognize typed usage counts and benchmark summaries, not credentials."""
if match.group(1).lower() != "token" or text[match.end(1) : match.end(1) + 1].lower() != "s":
return False
start = match.start(1)
# Recover the entire key: matching only its suffix would also exempt an
# unrelated credential such as access_total_tokens. Scan only this key.
while start and (text[start - 1].isalnum() or text[start - 1] in "_.-"):
start -= 1
end = match.end(1) + 1
name = text[start:end].lower()
if name not in _TOKEN_COUNT_NAMES:
return False
if text[end : end + 1] in {'"', "'"} and (not start or text[start - 1] != text[end]):
return False
value_start = match.start(2)
quote = text[value_start - 1] if value_start else ""
# The text sweep includes an adjacent JSON delimiter in the value. Require
# the whole value to be numeric so strings and trailing payloads stay visible.
if quote not in {'"', "'"} and re.fullmatch(r"[0-9]+[,}\]]?", match.group(2)):
return True
if name != "tokens":
return False
# Benchmark summaries use four numeric statistics or a signed numeric delta.
# Bound parsing independently of file size; other containers remain findings.
if quote == '"':
value_start -= 1
try:
value, _ = json.JSONDecoder(object_pairs_hook=tuple).raw_decode(text[value_start : value_start + 512])
except ValueError:
return False
if isinstance(value, str):
return re.fullmatch(r"[+-][0-9]+(?:\.[0-9]+)?", value) is not None
return (
isinstance(value, tuple)
and len(value) == 4
and {key for key, _ in value} == {"mean", "stddev", "min", "max"}
and all(type(number) in {int, float} and number >= 0 and (isinstance(number, int) or math.isfinite(number)) for _, number in value)
)
def _python_secret_assignment_target(node: ast.expr) -> str | None:
"""Final identifier bound by a simple assignment target, else None."""
if isinstance(node, ast.Name):

View File

@ -8,6 +8,7 @@ to read the same way. The quoted-key shapes below are what a JSON config uses.
from __future__ import annotations
import json
from pathlib import Path
import pytest
@ -151,3 +152,63 @@ def test_pretty_printed_json_reports_the_line_the_key_is_on(tmp_path: Path) -> N
root = _write_package(tmp_path, "config.json", '{\n "api_key": "' + SECRET + '"\n}')
assert [finding["line"] for finding in _secret_findings(root)] == [2]
@pytest.mark.parametrize("name", ["tokens", "total_tokens", "input_tokens", "output_tokens", "prompt_tokens", "completion_tokens", "cached_tokens", "reasoning_tokens"])
@pytest.mark.parametrize("value", [0, 84852])
@pytest.mark.parametrize("template", ['{"%s": %s}', '{\n "%s": %s,\n "duration_ms": 23332\n}', "%s: %s", "%s=%s"])
def test_numeric_model_token_counts_are_not_credentials(tmp_path: Path, name: str, value: int, template: str) -> None:
root = _write_package(tmp_path, "measurements.md", template % (name.upper() if "=" in template else name, value))
assert _secret_findings(root) == []
@pytest.mark.parametrize(
"text",
[
'{"total_tokens": "84852"}',
'{"total_tokens": "' + SECRET + '"}',
"total_tokens: 84852secret",
"access_tokens: 84852",
"api_key: 84852",
"password: 84852",
"access_total_tokens: 84852",
'{"access total_tokens": 84852}',
'{"access/total_tokens": 84852}',
'{"tokens": "84852"}',
'{"tokens": {"mean": "' + SECRET + '", "stddev": 1, "min": 0, "max": 2}}',
'{"tokens": {"api_key": "' + SECRET + '"}}',
'{"tokens": {"mean": true, "stddev": 1, "min": 0, "max": 2}}',
'{"tokens": {"mean": NaN, "stddev": 1, "min": 0, "max": 2}}',
'{"tokens": {"mean": 1e999, "stddev": 1, "min": 0, "max": 2}}',
'{"tokens": {"mean": "' + SECRET + '", "mean": 1, "stddev": 1, "min": 0, "max": 2}}',
'{"tokens": [["mean", 1], ["stddev", 1], ["min", 0], ["max", 2]]}',
'{"tokens": "+1700' + SECRET + '"}',
],
)
def test_token_count_exemption_preserves_credential_detection(tmp_path: Path, text: str) -> None:
root = _write_package(tmp_path, "config.md", text)
assert [finding["line"] for finding in _secret_findings(root)] == [1]
def test_numeric_token_count_does_not_hide_later_credential(tmp_path: Path) -> None:
root = _write_package(tmp_path, "config.md", '{"total_tokens": 84852}\napi_key: ' + SECRET)
assert [finding["line"] for finding in _secret_findings(root)] == [2]
def test_numeric_token_count_does_not_suppress_cloud_token_scan(tmp_path: Path) -> None:
token = "sk-" + "a1b2c3d4" * 4
root = _write_package(tmp_path, "config.md", '{"total_tokens": 84852}\nAuthorization: Bearer ' + token)
findings = scan_skill_dir(root)["findings"]
assert any(finding["rule_id"] == "secret-cloud-token" and finding["severity"] == "CRITICAL" for finding in findings)
@pytest.mark.parametrize("value", [{"mean": 3800.5, "stddev": 400, "min": 3200, "max": 4100}, "+1700", "-12.5"])
def test_benchmark_token_summaries_are_measurements(tmp_path: Path, value) -> None:
root = _write_package(tmp_path, "benchmark.md", json.dumps({"tokens": value}))
assert _secret_findings(root) == []

View File

@ -297,6 +297,7 @@ Output from Benchmark mode. Located at `benchmarks/<timestamp>/benchmark.json`.
- `configuration`: Must be `"with_skill"` or `"without_skill"` (the viewer uses this exact string for grouping and color coding)
- `run_number`: Integer run number (1, 2, 3...)
- `result`: Nested object with `pass_rate`, `passed`, `total`, `time_seconds`, `tokens`, `errors`
- `tokens`: Uses sibling `timing.json.total_tokens` when present (including zero), independently of duration fields in `grading.json`. If the token count is missing or null, aggregation retains the legacy `execution_metrics.output_chars` proxy, defaulting to zero if that is also missing.
- `run_summary`: Statistical aggregates per configuration
- `with_skill` / `without_skill`: Each contains `pass_rate`, `time_seconds`, `tokens` objects with `mean` and `stddev` fields
- `delta`: Difference strings like `"+0.50"`, `"+13.0"`, `"+1700"`

View File

@ -133,23 +133,25 @@ def load_run_results(benchmark_dir: Path) -> dict:
"total": grading.get("summary", {}).get("total", 0),
}
# Extract timing — check grading.json first, then sibling timing.json
# Read recorded tokens independently of the grading duration.
timing = grading.get("timing", {})
result["time_seconds"] = timing.get("total_duration_seconds", 0.0)
timing_file = run_dir / "timing.json"
if result["time_seconds"] == 0.0 and timing_file.exists():
if timing_file.exists():
try:
with open(timing_file, encoding="utf-8") as tf:
timing_data = json.load(tf)
result["time_seconds"] = timing_data.get("total_duration_seconds", 0.0)
result["tokens"] = timing_data.get("total_tokens", 0)
if result["time_seconds"] == 0.0:
result["time_seconds"] = timing_data.get("total_duration_seconds", 0.0)
result["tokens"] = timing_data.get("total_tokens")
except json.JSONDecodeError:
pass
# Extract metrics if available
metrics = grading.get("execution_metrics", {})
result["tool_calls"] = metrics.get("total_tool_calls", 0)
if not result.get("tokens"):
# Keep the legacy proxy only when no token count was recorded.
if result.get("tokens") is None:
result["tokens"] = metrics.get("output_chars", 0)
result["errors"] = metrics.get("errors_encountered", 0)

View File

@ -0,0 +1,119 @@
"""Regressions for skill-creator benchmark measurements."""
import json
import runpy
from pathlib import Path
import pytest
@pytest.fixture
def benchmark_script():
script = (
Path(__file__).resolve().parents[2]
/ "skills/public/skill-creator/scripts/aggregate_benchmark.py"
)
return runpy.run_path(str(script))
def write_run(root, *, grading_timing=None, timing=None, config="with_skill"):
run_dir = root / "eval-1" / config / "run-1"
run_dir.mkdir(parents=True)
grading = {
"summary": {"pass_rate": 1.0, "passed": 1, "failed": 0, "total": 1},
"execution_metrics": {"output_chars": 12450},
}
if grading_timing is not None:
grading["timing"] = grading_timing
(run_dir / "grading.json").write_text(json.dumps(grading), encoding="utf-8")
if timing is not None:
(run_dir / "timing.json").write_text(json.dumps(timing), encoding="utf-8")
return run_dir
def test_recorded_tokens_used_when_grading_already_has_duration(
tmp_path, benchmark_script
):
write_run(
tmp_path,
grading_timing={"total_duration_seconds": 23.3},
timing={"total_duration_seconds": 25.0, "total_tokens": 84852},
)
benchmark = benchmark_script["generate_benchmark"](tmp_path)
assert benchmark["runs"][0]["result"]["tokens"] == 84852
assert benchmark["runs"][0]["result"]["time_seconds"] == 23.3
assert benchmark["run_summary"]["with_skill"]["tokens"]["mean"] == 84852
@pytest.mark.parametrize("layout", ["workspace", "legacy"])
@pytest.mark.parametrize(
"grading_timing",
[None, {}, {"total_duration_seconds": 0}, {"total_duration_seconds": 23.3}],
)
@pytest.mark.parametrize("tokens", [0, 84852])
def test_recorded_tokens_preserved_in_both_layouts(
tmp_path, benchmark_script, layout, grading_timing, tokens
):
root = tmp_path / "runs" if layout == "legacy" else tmp_path
write_run(
root,
grading_timing=grading_timing,
timing={"total_duration_seconds": 23.3, "total_tokens": tokens},
)
benchmark = benchmark_script["generate_benchmark"](tmp_path)
assert benchmark["runs"][0]["result"]["tokens"] == tokens
assert benchmark["runs"][0]["result"]["time_seconds"] == 23.3
assert benchmark["run_summary"]["with_skill"]["tokens"]["mean"] == tokens
@pytest.mark.parametrize(
"timing_state", ["absent", "missing_tokens", "null_tokens", "invalid_json"]
)
@pytest.mark.parametrize("has_grading_duration", [False, True])
def test_missing_token_measurements_keep_legacy_character_fallback(
tmp_path, benchmark_script, timing_state, has_grading_duration
):
grading_timing = {"total_duration_seconds": 23.3} if has_grading_duration else None
timing = None
if timing_state in {"missing_tokens", "null_tokens"}:
timing = {"total_duration_seconds": 25.0}
if timing_state == "null_tokens":
timing["total_tokens"] = None
run_dir = write_run(tmp_path, grading_timing=grading_timing, timing=timing)
if timing_state == "invalid_json":
(run_dir / "timing.json").write_text("{invalid", encoding="utf-8")
benchmark = benchmark_script["generate_benchmark"](tmp_path)
assert benchmark["runs"][0]["result"]["tokens"] == 12450
expected_time = 23.3 if has_grading_duration else (25.0 if timing else 0.0)
assert benchmark["runs"][0]["result"]["time_seconds"] == expected_time
def test_recorded_tokens_propagate_to_summary_delta_and_markdown(
tmp_path, benchmark_script
):
write_run(
tmp_path,
grading_timing={"total_duration_seconds": 23.3},
timing={"total_tokens": 84852},
config="with_skill",
)
write_run(
tmp_path,
grading_timing={"total_duration_seconds": 20.0},
timing={"total_tokens": 10000},
config="without_skill",
)
benchmark = benchmark_script["generate_benchmark"](tmp_path)
assert benchmark["run_summary"]["without_skill"]["tokens"]["mean"] == 10000
assert benchmark["run_summary"]["delta"]["tokens"] == "+74852"
assert "| Tokens | 84852 ± 0 | 10000 ± 0 | +74852 |" in benchmark_script[
"generate_markdown"
](benchmark)