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
61 changes: 61 additions & 0 deletions .github/workflows/product-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: product-validation

on:
pull_request:
push:
branches: [main]
workflow_dispatch:
Comment thread
hudsonaikins marked this conversation as resolved.

permissions:
contents: read

jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
TABELLIO_ARTIFACT_BASE_URI: artifact+github://IntelIP/agent-permit-office/actions/runs/${{ github.run_id }}/tabellio-validation-${{ github.event.pull_request.head.sha || github.sha }}/.artifacts/tabellio/visual-captures/
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v6
with: {node-version: 22}
- uses: oven-sh/setup-bun@v2
with: {bun-version: "1.3.3"}
- name: Checkout pinned Tabellio runner
uses: actions/checkout@v6
with:
repository: IntelIP/Tabellio
ref: c4789837ee5ad779198141d6ffd2fea77792fbfb
path: .tabellio-runner
Comment thread
hudsonaikins marked this conversation as resolved.
persist-credentials: false
- name: Isolate validator from candidate scan
run: mv .tabellio-runner "$RUNNER_TEMP/tabellio-runner"
- uses: actions/setup-python@v6
with: {python-version: "3.12"}
- uses: astral-sh/setup-uv@v6
with: {enable-cache: true}
- name: Install Chromium runtime
run: bun x playwright@1.61.1 install --with-deps chromium
- name: Validate exact candidate
run: |
set -o pipefail
install -m 755 "$RUNNER_TEMP/tabellio-runner/scripts/tabellio-validator.mjs" "$RUNNER_TEMP/tabellio-validator"
ln -s "$RUNNER_TEMP/tabellio-runner/scripts/finalize-tabellio-visual-captures.mjs" "$RUNNER_TEMP/tabellio-design-captures"
PATH="$RUNNER_TEMP:$PATH" node "$RUNNER_TEMP/tabellio-runner/scripts/tabellio-validate.mjs" gate \
--repo . --repo-id IntelIP/agent-permit-office \
--base origin/main --commit HEAD --manifest tabellio.validation.json | tee tabellio-validation-result.json
- name: Upload validation result
if: always()
uses: actions/upload-artifact@v4
with:
name: tabellio-validation-${{ github.event.pull_request.head.sha || github.sha }}
path: |
tabellio-validation-result.json
refs/tabellio/validations/
.artifacts/tabellio/
Comment thread
hudsonaikins marked this conversation as resolved.
include-hidden-files: true
if-no-files-found: warn
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.DS_Store
.agent-permit/
.artifacts/

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ignore generated validation references

The new product-validation workflow uploads generated evidence from refs/tabellio/validations/, but this ignore change covers only .artifacts/. Consequently, running the required tabellio-validate gate locally leaves the validation references as untracked files, contrary to the new AGENTS.md instruction not to commit generated evidence and making potentially sensitive proof data easy to include with git add -A; ignore the generated refs directory as well.

Useful? React with 👍 / 👎.

.codex/
.coverage
.entire/
Expand Down
55 changes: 55 additions & 0 deletions .tabellio/validators.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"schemaVersion": "tabellio-adapter/v0.1",
"profiles": {
"static": {
"commands": [
["uv", "sync", "--frozen", "--all-extras", "--dev"],
["bun", "install", "--frozen-lockfile", "--cwd", "dashboard"],
["bun", "run", "--cwd", "dashboard", "playwright", "install", "chromium"],
["bun", "install", "--frozen-lockfile", "--cwd", "docs-site"],
["python3", "tools/release_check.py"]
],
"metrics": [],
"cost": {"telemetry": "not_applicable", "usd": null, "modelCalls": null, "toolCalls": null},
"summary": "Locked environment and release packaging checks"
},
"schema": {
"commands": [["uv", "run", "--frozen", "pytest", "-q", "tests/unit/test_artifacts.py", "tests/unit/test_models.py", "tests/unit/test_sarif.py", "tests/unit/test_rule_registry.py"]],
"metrics": [{"name": "scanner_artifact_contracts_pass", "unit": "boolean", "passValue": 1, "failValue": 0}],
"cost": {"telemetry": "not_applicable", "usd": null, "modelCalls": null, "toolCalls": null},
"summary": "Deterministic scanner artifact and SARIF schemas"
},
"semantic": {
"commands": [["uv", "run", "--frozen", "pytest", "-q", "tests/unit/test_baseline.py", "tests/unit/test_evals.py", "tests/unit/test_permit_engine.py", "tests/unit/test_policy.py"]],
"metrics": [{"name": "permit_policy_oracles_pass", "unit": "boolean", "passValue": 1, "failValue": 0}],
"cost": {"telemetry": "not_applicable", "usd": null, "modelCalls": null, "toolCalls": null},
"summary": "Permit decisions, policy boundaries, and evaluation oracles"
},
"workflow": {
"commands": [["uv", "run", "--frozen", "pytest", "-q", "tests/unit/test_cli.py", "tests/unit/test_cli_ci_mode.py", "tests/unit/test_demo.py", "tests/unit/test_fixtures.py", "tests/unit/test_github_action_packaging.py"]],
"metrics": [{"name": "cli_ci_proofpack_workflows_pass", "unit": "boolean", "passValue": 1, "failValue": 0}],
"cost": {"telemetry": "not_applicable", "usd": null, "modelCalls": null, "toolCalls": null},
"summary": "CLI, CI mode, fixtures, and packaged action workflows"
},
"visual": {
"commands": [["bun", "run", "--cwd", "dashboard", "build"], ["bun", "run", "--cwd", "dashboard", "design:capture"], ["node", "scripts/design/finalize-captures.mjs"]],
"metrics": [{"name": "visual_capture_count", "unit": "count", "pattern": "Visual capture count: ([0-9]+)"}],
"cost": {"telemetry": "not_applicable", "usd": null, "modelCalls": null, "toolCalls": null},
"summary": "Deterministic PermitGraph dashboard matrix with font and overflow checks"
},
"operational": {
"commands": [["uv", "run", "--frozen", "pytest", "-q", "tests/unit/test_model_provider.py", "tests/unit/test_db.py"]],
"metrics": [
{"name": "local_provider_and_storage_boundaries_pass", "unit": "boolean", "passValue": 1, "failValue": 0}
],
"cost": {"telemetry": "available", "usd": 0, "modelCalls": 0, "toolCalls": 0},
"summary": "Local provider and storage paths with no live model calls"
},
"security": {
"commands": [["uv", "run", "--frozen", "pytest", "-q", "tests/unit/test_capability_graph.py", "tests/unit/test_ci_workflows.py", "tests/unit/test_credential_refs.py", "tests/unit/test_mcp_config.py", "tests/unit/test_path_finder.py", "tests/unit/test_prompt_instructions.py"]],
"metrics": [{"name": "agent_attack_path_controls_pass", "unit": "boolean", "passValue": 1, "failValue": 0}],
"cost": {"telemetry": "not_applicable", "usd": null, "modelCalls": null, "toolCalls": null},
"summary": "Credential, MCP, workflow, prompt, and attack-path controls"
}
}
}
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,11 @@ For docs-only work:
cd docs-site
bun run build
```

## Product validation gate

- Unit tests are structural evidence only. Before review or merge readiness, run the committed `tabellio.validation.json` against the exact candidate commit with `tabellio-validate gate`.
- Schema, semantic, workflow, operational, and security evidence must pass. `blocked` is not `passed`; a new commit invalidates earlier evidence.
- Preserve PermitGraph's boundary: validators may scan committed fixtures and this repository, but never execute target repository code, MCP servers, target workflows, or paid model providers.
- Keep validation external cost at zero. Upload generated evidence from CI; do not commit it.
- Track rollout and failures in Plane item `APO-120`.
1 change: 1 addition & 0 deletions dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"lint": "eslint .",
"preview": "vite preview",
"test:e2e": "playwright test",
"design:capture": "playwright test --config playwright.design.config.ts",
"test:unit": "vitest run tests/unit"
},
"dependencies": {
Expand Down
19 changes: 19 additions & 0 deletions dashboard/playwright.design.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { defineConfig } from "@playwright/test";

import baseConfig from "./playwright.config";

export default defineConfig({
...baseConfig,
reporter: [["list"]],
testMatch: /design-memory\.capture\.ts/,
use: {
...baseConfig.use,
screenshot: "off",
trace: "off",
Comment on lines +9 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Pin locale and timezone for deterministic captures

When this capture configuration runs on a host with a different locale or timezone, identical commits render different visible dates and times because the dashboard calls toLocaleDateString(undefined) and toLocaleTimeString(undefined) (liveApiProjection.ts:425 and QueueStatusPanels.tsx:315). Since this context does not set locale or timezoneId, baseline candidates can differ solely by runner environment; pin both options in the design configuration.

Useful? React with 👍 / 👎.

},
webServer: {
command: "VITE_AGENT_PERMIT_API_URL=/api bun run build && bun run preview -- --host 127.0.0.1 --port 5173",
reuseExistingServer: false,
url: "http://127.0.0.1:5173",
},
});
122 changes: 122 additions & 0 deletions dashboard/tests/e2e/design-memory.capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { mkdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";

import { expect, test } from "@playwright/test";

type Profile = {
policy: {
surfaces: Array<{ id: string; kind: "route" | "component"; target: string; viewports: string[]; themes: string[]; states: string[] }>;
viewports: Array<{ id: string; width: number; height: number }>;
};
};

const dashboardRoot = process.cwd();
const repoRoot = resolve(dashboardRoot, "..");
const profile = JSON.parse(readFileSync(resolve(repoRoot, "design/product.design.json"), "utf8")) as Profile;
type DashboardFixtureSource = {
generatedAt: string;
findings: Array<Record<string, unknown>>;
repos: Array<Record<string, unknown>>;
runs: Array<Record<string, unknown>>;
};

const dashboardSnapshot = JSON.parse(
readFileSync(resolve(dashboardRoot, "src/data/generated/dashboardSnapshot.json"), "utf8"),
) as DashboardFixtureSource;
const apiSnapshot = JSON.stringify({
generatedAt: dashboardSnapshot.generatedAt,
repositories: dashboardSnapshot.repos.map((repo) => ({
id: repo.id,
label: repo.label,
local_path: repo.source,
})),
runs: dashboardSnapshot.runs.map((run) => {
const repo = dashboardSnapshot.repos.find((candidate) => candidate.id === run.repoId);
const metrics = (run.metrics ?? {}) as Record<string, unknown>;
const artifacts = Array.isArray(run.artifacts) ? run.artifacts : [];
return {
artifact_dir: artifacts[0] ?? "",
branch: "fixture",
cache_hit_ratio: metrics.cacheHitRatio ?? 0,
cached_tokens: metrics.cachedTokens ?? 0,
completed_at: run.completedAt,
controls_count: metrics.controls ?? 0,
findings_count: metrics.findings ?? 0,
graph_paths_count: metrics.graphPaths ?? 0,
local_path: repo?.source ?? "fixture",
model: null,
model_calls: metrics.modelCalls ?? 0,
permit_status: run.status,
repository_id: run.repoId,
repository_label: repo?.label ?? run.repoId,
run_id: run.id,
status: "completed",
total_tokens: metrics.totalTokens ?? 0,
};
}),
findings: dashboardSnapshot.findings.map((finding) => {
const metrics = (finding.metrics ?? {}) as Record<string, unknown>;
return {
cache_hit_ratio: metrics.cacheHitRatio ?? 0,
cached_tokens: metrics.cachedTokens ?? 0,
controls_count: metrics.controls ?? 0,
finding_id: finding.id,
findings_count: metrics.findings ?? 1,
graph_paths_count: metrics.graphPaths ?? 0,
local_path: finding.source,
model_calls: metrics.modelCalls ?? 0,
permit_status: finding.status,
recommendation: finding.remediation,
repository_label: finding.repo,
risk: finding.summary,
rule_id: finding.rule,
run_id: finding.runId,
severity: finding.severity,
title: finding.title,
total_tokens: metrics.totalTokens ?? 0,
};
}),
});
const output = resolve(repoRoot, ".artifacts/tabellio/visual-captures");
mkdirSync(output, { recursive: true });
Comment on lines +80 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear stale screenshots before each capture run

When design:capture is rerun in a working tree after a surface, viewport, theme, or state ID has been removed or renamed, this only creates the output directory and leaves old PNGs in place. The finalizer then scans the whole directory, so its capture count and baseline candidate can include evidence produced by an earlier commit rather than the current matrix; remove and recreate the capture directory before generating screenshots.

Useful? React with 👍 / 👎.


test.describe("Tabellio visual capture matrix", () => {
test.describe.configure({ mode: "serial" });

for (const surface of profile.policy.surfaces) {
if (surface.kind !== "route") throw new Error(`Capture adapter missing for component surface: ${surface.id}`);
for (const viewportId of surface.viewports) for (const theme of surface.themes) for (const state of surface.states) {
if (state !== "default") throw new Error(`Capture adapter missing for state: ${surface.id}/${state}`);
const viewport = profile.policy.viewports.find((entry) => entry.id === viewportId);
if (!viewport) throw new Error(`Unknown viewport: ${viewportId}`);
const id = `${surface.id}--${viewportId}--${theme}--${state}`;

test(id, async ({ page }) => {
let fixtureRequests = 0;
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.emulateMedia({ colorScheme: theme as "light" | "dark", reducedMotion: "reduce" });
await page.route("**/api/snapshot", async (route) => {
fixtureRequests += 1;
await route.fulfill({ body: apiSnapshot, contentType: "application/json", status: 200 });
});
await page.goto(surface.target, { waitUntil: "domcontentloaded" });
await page.evaluate((selectedTheme) => {
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(selectedTheme);
document.documentElement.style.colorScheme = selectedTheme;
}, theme);
await page.addStyleTag({ content: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Verify reduced motion before suppressing animations

If a dashboard component ignores prefers-reduced-motion, this injected rule still forcibly disables every animation and transition, so the visual gate passes despite the profile explicitly requiring reduced-motion behavior and forbidding motion without it. Keep deterministic screenshots, but first verify the application itself has suppressed or suitably reduced motion under the emulated media preference rather than masking violations with test CSS.

Useful? React with 👍 / 👎.

await page.evaluate(() => document.fonts.ready);

await expect(page.getByRole("heading", { name: "Repository findings" })).toBeVisible();
await expect(page.getByText("Live Worker data", { exact: true })).toBeVisible();
expect(fixtureRequests).toBeGreaterThan(0);
await expect(page.getByText("unknown policy", { exact: true })).toHaveCount(0);
Comment thread
hudsonaikins marked this conversation as resolved.
await expect(page.locator("html")).toHaveClass(new RegExp(`(?:^|\\s)${theme}(?:\\s|$)`));
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain("DM Sans");
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
await page.screenshot({ path: resolve(output, `${id}.png`), animations: "disabled" });
});
}
}
});
11 changes: 11 additions & 0 deletions design/baselines/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"schemaVersion": "tabellio-visual-baselines/v0.1",
"status": "proposed",
"productId": "permitgraph/dashboard",
"profilePath": "design/product.design.json",
"profileDigest": "03ef2f28268a93862651ebd0a8ae8ac2fd24b350619e968cc9a43c56e5189e7a",
"sourceCommit": null,
"approvedAt": null,
"approvedBy": null,
"captures": []

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Record the approved visual baseline

On subsequent dashboard changes, this empty, still-proposed manifest provides no approved captures or digests against which to detect a visual regression. The visual profile only checks that four screenshots were produced (.tabellio/validators.json:35-36), so a materially altered UI can pass while merely generating a new candidate; persist the human-approved captures and source commit in this manifest so the design memory is actually durable.

Useful? React with 👍 / 👎.

}
5 changes: 5 additions & 0 deletions design/decisions/0001-design-memory-bootstrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Design memory bootstrap

PermitGraph keeps its current visual authority in `dashboard/src/index.css` and shadcn configuration in `dashboard/components.json`. The portable product profile references those sources and preserves the separation between deterministic permit evidence and model explanation.

Initial screenshot baselines remain proposed until dashboard routes and permit states are captured from an exact committed head and approved by a human.
63 changes: 63 additions & 0 deletions design/product.design.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"schemaVersion": "tabellio-product-design/v0.1",
"productId": "permitgraph/dashboard",
"title": "PermitGraph Dashboard",
"audience": ["Security engineers", "Platform operators", "AI governance teams"],
"platforms": ["web", "docs"],
"visualThesis": "Calm security-control software that makes permit state, evidence, risk paths, and remediation decisions immediately legible without alarmist decoration.",
"sources": {
"tokens": [
{"path": "dashboard/src/index.css", "digest": "25661b152f617fdc477e91eba08c0eb6b969e70c5a168b47db697ec74ee25581"}
],
"components": [
{"path": "dashboard/components.json", "digest": "49101704a7f660f485a362e1d31d0f123877c34b58bc0cdc7796e99a7fe4438a"}
],
"decisions": ["AGENTS.md", "design/decisions/0001-design-memory-bootstrap.md"]
},
"referenceLock": {
"primary": "Existing PermitGraph dashboard tokens and deterministic evidence-review workflow",
"preserve": [
"DM Sans typography",
"Muted teal primary actions",
"Distinct approved, review, blocked, critical, agent, and artifact semantics",
"Evidence-first information hierarchy",
"True black high-contrast dark surfaces"
],
"borrow": ["Security operations patterns from real control-plane interfaces"],
"roleRules": [
"Permit colors communicate deterministic status only",
"Risk emphasis must remain subordinate to evidence and remediation",
"Use Phosphor icons for product actions"
],
"reject": [
"Cyberpunk decoration",
"Red-first fear signaling without evidence",
"Generic compliance dashboard card grids",
"Model prose presented as deterministic finding state"
],
"mediaStrategy": "Prefer graph paths, proof-pack artifacts, and real scanner output over decorative illustration."
},
"policy": {
"themes": ["light", "dark"],
"viewports": [
{"id": "desktop", "width": 1440, "height": 1000, "deviceScaleFactor": 1},
{"id": "mobile", "width": 390, "height": 844, "deviceScaleFactor": 1}
],
"states": ["default", "hover", "focus", "disabled", "loading", "empty", "error", "approved", "review", "blocked", "critical"],
"surfaces": [
{"id": "dashboard", "kind": "route", "target": "/", "viewports": ["desktop", "mobile"], "themes": ["light", "dark"], "states": ["default"]}
],
"forbiddenPatterns": [
"Status conveyed by color alone",
"Raw colors outside canonical token definitions",
"Target repository code execution during validation",
"Motion without reduced-motion behavior"
],
"accessibility": {
"standard": "WCAG 2.2 AA",
"keyboardRequired": true,
"reducedMotionRequired": true
}
},
"baselines": {"manifest": "design/baselines/manifest.json"}
}
27 changes: 27 additions & 0 deletions scripts/design/finalize-captures.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { execFileSync, spawnSync } from "node:child_process";

const sourceCommit = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
const artifactBaseUri = process.env.TABELLIO_ARTIFACT_BASE_URI?.trim();
if (!artifactBaseUri) {
throw new Error("TABELLIO_ARTIFACT_BASE_URI is required; visual evidence without a durable URI is blocked.");
}

const args = [
"--repo", ".",
"--profile", "design/product.design.json",
"--captures-dir", ".artifacts/tabellio/visual-captures",
"--artifact-base-uri", artifactBaseUri,
"--source-commit", sourceCommit,
"--out", ".artifacts/tabellio/visual-baseline-candidate.json",
];
const script = process.env.TABELLIO_DESIGN_CAPTURES_SCRIPT?.trim();
const command = script ? process.execPath : "tabellio-design-captures";
const result = spawnSync(command, script ? [script, ...args] : args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
if (result.status !== 0) process.exit(result.status ?? 1);
const payload = JSON.parse(result.stdout);
console.log(`Visual capture count: ${payload.captureCount}`);
Loading
Loading