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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.10.2 — 2026-09-15

- Bind evidence explorer links to SHA-256 of the actual loaded audit bytes. A changed recording with the same case coordinates no longer reports the original evidence as restored. Display the loaded fingerprint and distinguish content identity from authorship.
- Keep legacy links readable with an explicit missing-identity notice; reject duplicate parameters and preserve report/download access when browser hashing is unavailable. Test changed bytes under an unchanged manifest, fresh links, retry, mobile layouts and clipboard fallback.
- Task contracts, metric calculations and historical audit records are unchanged.

## 0.10.1 — 2026-09-15

- Add a reduced-suite regression that removes `cas-type-sensitivity` from `durable-kv` at seed 17: `boolean-equals-one` survives, the recomputed audit reports 7 of 8 at 0.875, and the weakest margin is 0. The published 0.10.0 wheel already produces these values and includes surviving valid controls in the weakest margin. This release adds the regression and clarifies the explanation; it does not change the metric calculation.
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ lets you compare revisions side by side, switch between correct and faulty
implementations, and step through the tool call that changed the state. It replays the
committed Docker audits without a model API or installation.
Share the exact case and trace step with **Copy evidence link**, return from
details to the case list, and retry failed sections independently.
details to the case list, and retry failed sections independently. New links include
SHA-256 of the loaded audit bytes: changed evidence is flagged before restoring a
view, while legacy links disclose that the original audit identity is unknown.
The fingerprint identifies content, not its author.
[Explorer guide](docs/explorer.md).

[![EvalArc v0.3: score rises from 90% to 93.75% while a check regresses](docs/assets/regression-lab.png)](https://huggingface.co/spaces/glayguo/evalarc)
Expand Down
4 changes: 3 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ EvalArc 关注智能体实际完成的结果,以及支撑评分结论的证据
可以并排比较两个版本,查看一处退步、两处改进,再逐步检查工具调用和状态变化。
页面读取仓库保存的 Docker 审计记录,无需安装,也不调用模型。
使用 **Copy evidence link** 分享具体案例和步骤;打开详情后可返回原案例,
某一分区加载失败时可单独重试。[交互使用说明](docs/explorer.md)。
某一分区加载失败时可单独重试。新链接携带实际加载审计文件的 SHA-256,
记录变化时会先提示并停止恢复;旧链接明确说明未保存原始审计身份。
指纹标识内容,不认证作者。[交互使用说明](docs/explorer.md)。

[![EvalArc v0.3:分数上升,一项检查却退步](docs/assets/regression-lab.png)](https://huggingface.co/spaces/glayguo/evalarc)

Expand Down
6 changes: 6 additions & 0 deletions docs/explorer.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ The browser checks exercise desktop and mobile layouts, clipboard denial,
reload/history restoration, keyboard focus, partial loading and retries.
They do not constitute a full accessibility certification or a browser support
matrix. The original casebook and audit bytes retain their recorded provenance.

## Evidence identity in shared links

Version 2 links include SHA-256 of the exact audit response bytes, together with the task, control, case, seed and trace step. The explorer hashes the fetched audit itself rather than trusting a potentially stale site manifest. If it differs, the linked view is not restored; choose a current case or open the saved original audit. Version 1 links show an explicit legacy notice because they cannot identify the original audit. Duplicate parameters and unsupported versions are rejected.

This is content identity, not an author signature or a substitute for offline evidence verification. HTTPS (or localhost) provides browser hashing. If it is unavailable, reports and downloads remain accessible while evidence-bound sharing is disabled.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "evalarc-evidence-site",
"version": "0.10.0",
"version": "0.10.2",
"private": true,
"description": "Browser checks for the static EvalArc evidence explorer",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "evalarc"
version = "0.10.1"
version = "0.10.2"
description = "Auditable task environments and evaluations for coding and tool-using agents."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
52 changes: 51 additions & 1 deletion scripts/check_site.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ async function main() {
await app.locator("#share-case").click();
const shared = await app.locator("#share-url").inputValue();
const sharedParams = new URLSearchParams(new URL(shared).hash.slice(1));
assert.equal(sharedParams.get("v"), "2");
assert.equal(sharedParams.get("audit"), manifest.files["support/audit.json"]);
assert.equal(sharedParams.get("case"), "retry-after-commit");
assert.equal(sharedParams.get("step"), "3");
assert.equal(await app.locator("body").evaluate(() => document.activeElement.id), "share-url");
Expand Down Expand Up @@ -292,7 +294,7 @@ async function main() {
assert.equal(await page.locator("#changed-cases button").count(), 3);
assert.equal(await page.locator("#repeat-attempts a").count(), 3);
assert.equal(await page.locator("#cases button").count(), 15);
assert.match(await page.locator("#share-status").innerText(), /Shared evidence restored/);
assert.match(await page.locator("#share-status").innerText(), /Legacy link.*original audit identity was not recorded/);
assert.equal(await page.locator("body").evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
assert.deepEqual(errors, []);
failures.add("skill-impact/lab.json");
Expand All @@ -304,6 +306,54 @@ async function main() {
assert.equal(await page.locator(".trial").count(),9);
results.push({independentSectionRetry:true, partialTaskPack:true, pendingLinkRestored:true, researchRetry:true, errors});
await page.close();
const identityPage = await browser.newPage({viewport:{width:390,height:1000}});
try {
const auditBytes = fs.readFileSync(path.join(root, "support/audit.json"));
const digest = require("node:crypto").createHash("sha256").update(auditBytes).digest("hex");
const link = base + `#v=2&audit=${digest}&pack=support&control=new-key-on-retry&case=retry-after-commit&seed=17&step=3`;
await identityPage.goto(link);
await identityPage.locator("#share-status").filter({hasText:"Shared evidence restored"}).waitFor();
assert.equal(await identityPage.locator("#trace-step").inputValue(), "3");
const changed = JSON.parse(auditBytes);
changed.reference.created_at = "2026-09-15T00:00:00+00:00";
const changedBytes = Buffer.from(JSON.stringify(changed) + "\n");
const changedDigest = require("node:crypto").createHash("sha256").update(changedBytes).digest("hex");
assert.notEqual(changedDigest, digest);
// Same task, controls, cases and step coordinates, but different saved
// audit bytes. The manifest deliberately stays old, as in a mixed deploy.
await identityPage.route("**/support/audit.json", route =>
route.fulfill({status:200, contentType:"application/json", body:changedBytes}));
await identityPage.reload();
await identityPage.locator("#share-status").filter({hasText:"Evidence changed"}).waitFor();
assert.equal(await identityPage.locator("#trace-step").inputValue(), "0");
assert.match(await identityPage.locator("#share-status").innerText(), /linked view was not restored/);
assert.equal(JSON.parse(await identityPage.locator("#provenance").textContent()).audit_sha256, changedDigest);
await identityPage.locator("#next-step").click();
const fresh = identityPage.url();
assert.equal(new URLSearchParams(new URL(fresh).hash.slice(1)).get("audit"), changedDigest);
await identityPage.reload();
await identityPage.locator("#share-status").filter({hasText:"Shared evidence restored"}).waitFor();
assert.equal(await identityPage.locator("#trace-step").inputValue(), "1");
await identityPage.evaluate(hash => { location.hash = hash; },
new URL(fresh).hash + "&audit=" + digest);
await identityPage.locator("#share-status").filter({hasText:"not supported"}).waitFor();
assert.equal(await identityPage.locator("#trace-step").inputValue(), "1");
await identityPage.goto(base + "#v=1&pack=support&control=new-key-on-retry&case=retry-after-commit&seed=17&step=3");
await identityPage.locator("#share-status").filter({hasText:"Legacy link"}).waitFor();
assert.equal(await identityPage.locator("#trace-step").inputValue(), "3");
await identityPage.addInitScript(() => {
Object.defineProperty(crypto, "subtle", {value:undefined, configurable:true});
});
await identityPage.goto(link);
await identityPage.reload();
await identityPage.locator("#workspace").waitFor({state:"visible"});
assert(await identityPage.locator("#share-case").isDisabled());
assert.match(await identityPage.locator("#audit-identity").innerText(), /fingerprint unavailable/);
assert.match(await identityPage.locator("#share-status").innerText(), /linked view was not restored/);
assert(await identityPage.locator("#suite-workspace").isVisible());
results.push({auditByteIdentity:true, changedAuditRejected:true, freshLinkRestored:true,
duplicateHashRejected:true, legacyLinkDisclosed:true, missingCryptoRecovery:true});
} finally { await identityPage.close(); }
}
console.log(JSON.stringify({url:base, checks:results}, null, 2));
} finally {
Expand Down
45 changes: 37 additions & 8 deletions site/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,24 @@ const stories = {
reference: "The known-good control satisfies every recorded check. Compare its behavior with a deliberately faulty implementation to audit the grader.",
};
let audits = {}, pack = "support", evaluation, selectedCase;
const auditDigests = {};
let comparison, baseline, current;
const pretty = (data) => JSON.stringify(data, null, 2);
const percent = (value) => value === null ? "Unassessed" : `${Number((value * 100).toFixed(4))}%`;
const repetitions = {};
async function fetchRecord(path) {
async function fetchRecord(path, withIdentity = false) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(path, {signal: controller.signal});
if (!response.ok) throw new Error(`Evidence request failed (${response.status})`);
return await response.json();
if (!withIdentity) return await response.json();
const bytes = await response.arrayBuffer();
const record = JSON.parse(new TextDecoder("utf-8", {fatal: true}).decode(bytes));
const digest = globalThis.crypto?.subtle
? await crypto.subtle.digest("SHA-256", bytes) : null;
return {record, sha256: digest
? Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("") : null};
} finally {
clearTimeout(timer);
}
Expand Down Expand Up @@ -150,6 +157,10 @@ function choosePack(nextPack, view) {
if (view?.control && [...select.options].some(option => option.value === view.control)) select.value = view.control;
$("report-link").href = `${packs[pack].path}/index.html`;
$("download-link").href = `${packs[pack].path}/audit.json`;
$("share-case").disabled = !auditDigests[pack];
$("audit-identity").textContent = auditDigests[pack]
? `Loaded audit SHA-256: ${auditDigests[pack].slice(0, 16)}… Full fingerprint in the reproduction record.`
: "Audit fingerprint unavailable. Open this page over HTTPS to share an evidence-bound link; the report and downloads still work.";
chooseControl(view);
}
function chooseControl(view) {
Expand Down Expand Up @@ -182,6 +193,7 @@ function chooseControl(view) {
$("cases").append(button);
});
$("provenance").textContent = pretty({
audit_sha256: auditDigests[pack],
task: evaluation.task, created_at: evaluation.created_at, seeds: evaluation.seeds,
evalarc_version: evaluation.evalarc_version, candidate_sha256: evaluation.candidate_sha256,
grader_sha256: evaluation.grader_sha256, cases_sha256: evaluation.cases_sha256,
Expand Down Expand Up @@ -229,19 +241,26 @@ function showStep() {
}
function viewFragment() {
return "#" + new URLSearchParams({
v: "1", pack, control: $("control").value, case: selectedCase.case_id,
v: "2", audit: auditDigests[pack], pack, control: $("control").value, case: selectedCase.case_id,
seed: String(selectedCase.seed), step: $("trace-panel").hidden ? "0" : $("trace-step").value,
});
}
function rememberView() {
$("share-url").hidden = true;
$("share-status").textContent = "";
try { history.replaceState(null, "", viewFragment()); } catch { /* Restricted embeds can still copy links. */ }
if (auditDigests[pack]) {
try { history.replaceState(null, "", viewFragment()); } catch { /* Restricted embeds can still copy links. */ }
}
}
function restoreView(focus = true) {
if (!location.hash.includes("=")) return false;
const params = new URLSearchParams(location.hash.slice(1));
if (location.hash.length > 2048 || params.get("v") !== "1" || !Object.hasOwn(packs, params.get("pack"))) {
const version = params.get("v");
const keys = new Set(["v", "audit", "pack", "control", "case", "seed", "step"]);
if (location.hash.length > 2048 || !["1", "2"].includes(version) || !Object.hasOwn(packs, params.get("pack")) ||
[...params.keys()].some(key => !keys.has(key) || params.getAll(key).length !== 1) ||
(version === "2" && !/^[a-f0-9]{64}$/.test(params.get("audit") || "")) ||
(version === "1" && params.has("audit"))) {
$("share-status").textContent = "This evidence link is not supported. Choose a task, control and case below.";
return false;
}
Expand All @@ -250,6 +269,13 @@ function restoreView(focus = true) {
$("share-status").textContent = `The linked ${requestedPack} evidence is unavailable. Retry the missing task pack to open this view.`;
return false;
}
if (version === "2" && params.get("audit") !== auditDigests[requestedPack]) {
$("share-url").hidden = true;
$("share-status").textContent = auditDigests[requestedPack]
? `Evidence changed: this link expects audit ${params.get("audit").slice(0, 16)}…, but the loaded audit is ${auditDigests[requestedPack].slice(0, 16)}…. The linked view was not restored. Choose a case to review the current recording or open the original saved audit.`
: "This link's audit identity could not be checked. The linked view was not restored. Open this page over HTTPS or use the original saved audit.";
return false;
}
const step = Number(params.get("step"));
const view = {control: params.get("control"), case: params.get("case"), seed: params.get("seed"),
step: Number.isSafeInteger(step) && step >= 0 ? step : 0};
Expand All @@ -259,7 +285,9 @@ function restoreView(focus = true) {
(selectedCase.trace?.length ? Number($("trace-step").value) === view.step : view.step === 0);
$("share-url").hidden = true;
$("share-status").textContent = exact
? "Shared evidence restored. The link selects a recorded case and trace step."
? version === "2"
? "Shared evidence restored. The loaded audit bytes match the link's SHA-256; this checks content identity, not authorship."
: "Legacy link: showing the selected case in the current recording. The original audit identity was not recorded in this link."
: "Some link values were outside this recording. Showing the nearest available view; check the selected case and step.";
if (focus) $("case-title").focus();
return true;
Expand All @@ -270,7 +298,7 @@ $("share-case").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(url.href);
$("share-url").hidden = true;
$("share-status").textContent = "Evidence link copied: task, control, seed, case and trace step.";
$("share-status").textContent = "Evidence link copied with the loaded audit's SHA-256, task, control, seed, case and trace step.";
} catch {
$("share-url").hidden = false;
$("share-url").value = url.href;
Expand Down Expand Up @@ -327,10 +355,11 @@ for (const name of Object.keys(packs)) $(name + "-task").addEventListener("click
});
async function loadAudits() {
await Promise.allSettled(Object.entries(packs).filter(([name]) => !audits[name]).map(async ([name, config]) => {
const record = await fetchRecord(`${config.path}/audit.json`);
const {record, sha256} = await fetchRecord(`${config.path}/audit.json`, true);
if (!Array.isArray(record.reference?.cases) || !Array.isArray(record.mutants) ||
record.mutants.some(row => !Array.isArray(row.evaluation?.cases))) throw new Error("Incomplete audit evidence");
audits[name] = record;
auditDigests[name] = sha256;
$(name + "-task").disabled = false;
}));
const available = Object.keys(audits), missing = Object.keys(packs).filter(name => !audits[name]);
Expand Down
1 change: 1 addition & 0 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ <h2 id="coverage-title">A perfect score.<br>How much coverage remains?</h2>
<section class="case-detail" id="case-detail" aria-labelledby="case-title">
<div class="case-heading"><h3 id="case-title" tabindex="-1"></h3><span id="case-verdict" class="badge"></span></div>
<div class="case-actions"><button id="back-to-cases" type="button">Back to cases &#8593;</button><button id="share-case" type="button">Copy evidence link</button></div>
<p id="audit-identity" class="caption"></p>
<p id="share-status" class="caption" role="status"></p>
<input id="share-url" aria-label="Evidence link to copy" readonly hidden>
<div id="case-checks" class="check-list"></div>
Expand Down
2 changes: 1 addition & 1 deletion src/evalarc/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Auditable evaluations for AI agents."""

__version__ = "0.10.1"
__version__ = "0.10.2"
Loading