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
45 changes: 45 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Verify and package
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: verify-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4, official tag checked 2026-10-08
with:
persist-credentials: false
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
with:
version: 11.19.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24.19.0
- run: pnpm audit
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm verify
- run: pnpm package
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: failure()
with:
name: synthetic-test-failure
path: |
artifacts/
playwright-report/
retention-days: 3
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: accesslab-production
path: |
dist/
artifacts/activation-performance.json
retention-days: 7
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ test-results/
artifacts/
dist/
.output-test/

.pnpm-cache/
__pycache__/
4 changes: 2 additions & 2 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
store-dir=/workspace/.pnpm-store
cache-dir=/workspace/.pnpm-cache
store-dir=.pnpm-store
cache-dir=.pnpm-cache
strict-peer-dependencies=false
2 changes: 2 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
Project-owned code: MIT, see LICENSE. Dependencies retain their own licenses; this project does not relicense them. axe-core (Deque Systems) uses MPL-2.0; its source and license are available at https://github.com/dequelabs/axe-core and in node_modules/axe-core/LICENSE. React MIT; WXT MIT; Playwright Apache-2.0; pnpm MIT. Preserve notices in production packaging.

Color-vision matrices: Machado, Oliveira & Fernandes (2009), DOI 10.1109/TVCG.2009.113, full-severity supplementary tables. Values verified against Colorspacious cvd.py (MIT, Copyright 2014–2015 Colorspacious developers), revision 58948923b706879a54071568c7501be3f108797c. License text retained in docs/licenses/colorspacious-MIT.txt. No Colorspacious executable code or dependency is bundled.

Production builds emit a package/version/SPDX inventory and full license text for every retained third-party module under licenses/. Current runtime packages are React, ReactDOM, Scheduler, axe-core, WXT and @wxt-dev/browser. The WXT npm package omits its license file; WXT and its browser subpackage use the repository MIT text preserved in docs/licenses/WXT-MIT.txt, retrieved from official wxt-dev/wxt revision c89749b30fb61745d174c77b19a0d7ca3bd14992. Exact version tag v0.21.4 was unavailable; license metadata in installed packages is MIT. The packaging script includes unminified axe-core source form in licenses/axe-core-source.txt; the corresponding locked project build source is in this repository.
20 changes: 20 additions & 0 deletions apps/extension/license-inventory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { existsSync,readFileSync } from 'node:fs';
import { dirname,join,parse } from 'node:path';
import { createHash } from 'node:crypto';
import type { Plugin } from 'vite';
// Record packages actually retained in each production bundle, never machine paths.
export function licenseInventory():Plugin{return{name:'accesslab-license-inventory',generateBundle(_options,bundle){
const packages=new Map<string,{name:string;version:string;license:string}>();
for(const output of Object.values(bundle))if(output.type==='chunk')for(const [id,module] of Object.entries(output.modules)){
if(!module.renderedLength||!id.includes('node_modules'))continue;let dir=dirname(id.split('?')[0]);
while(dir!==parse(dir).root){const manifest=join(dir,'package.json');if(existsSync(manifest)){
const pkg=JSON.parse(readFileSync(manifest,'utf8')) as {name:string;version:string;license:string};if(!pkg.name)break;
const name=pkg.name.replace(/[^a-zA-Z0-9._-]/g,'-');packages.set(pkg.name,{name:pkg.name,version:pkg.version,license:pkg.license});
let license=['LICENSE','LICENSE.md','LICENSE.txt','license','license.md'].map(file=>join(dir,file)).find(existsSync);
if(!license&&['wxt','@wxt-dev/browser'].includes(pkg.name))license=join(import.meta.dirname,'../../docs/licenses/WXT-MIT.txt');
if(!license)throw Error(`Missing license text for bundled ${pkg.name}`);
this.emitFile({type:'asset',fileName:`licenses/${name}.txt`,source:readFileSync(license,'utf8')});break;
}dir=dirname(dir);}
}
if(packages.size){const content=JSON.stringify([...packages.values()].sort((a,b)=>a.name<b.name?-1:a.name>b.name?1:0),null,2)+'\n';const hash=createHash('sha256').update(content).digest('hex').slice(0,12);this.emitFile({type:'asset',fileName:`licenses/inventory-${hash}.json`,source:content});}
}};}
3 changes: 2 additions & 1 deletion apps/extension/wxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { licenseInventory } from './license-inventory';
import { defineConfig } from 'wxt';
export default defineConfig({
outDir: process.env.ACCESSLAB_TEST === '1' ? '.output-test' : '.output',
vite: () => ({ define: { __ACCESSLAB_TEST__: JSON.stringify(process.env.ACCESSLAB_TEST === '1') } }),
vite: () => ({ plugins:[licenseInventory()],define: { __ACCESSLAB_TEST__: JSON.stringify(process.env.ACCESSLAB_TEST === '1') } }),
manifest: { name: 'AccessLab', description: 'Find accessibility barriers. Inspect the cause. Verify the fix.', permissions: ['activeTab', 'scripting', 'storage'], ...(process.env.ACCESSLAB_TEST === '1' ? { host_permissions: ['http://127.0.0.1/*'] } : {}), action: { default_title: 'Open AccessLab' } },
});
2 changes: 2 additions & 0 deletions docs/decisions/003-dependency-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ Current lockfile: pnpm's frozen-install supply-chain policy check passed 324 ent
Future dependency changes must record their assessment here or in task evidence. Prefer existing dependencies and local code over unnecessary packages.

T06: declaring already-locked React 19.3.0, ReactDOM 19.3.0 and axe-core 4.14.0 at root for component/browser test access. Rechecked official metadata first: React/ReactDOM point to github.com/react/react, Testing Library to testing-library/react-testing-library; provenance metadata present; no installation lifecycle hooks. axe assessment above. No new version selected.

T15: no new npm package/version added. Rechecked pnpm audit against 324 locked entries: zero known advisories. CI uses official actions/checkout, actions/setup-node, actions/upload-artifact and pnpm/action-setup v4 tag commit IDs queried from their official GitHub repositories on 2026-10-08 and pinned in the workflow. Playwright browser/system libraries remain fetched by its official installer with verification intact. Bundle graph inventory identifies six runtime packages and emits full license text; WXT/npm browser package omit license text, so official project MIT license was retrieved at pinned repository revision c89749b30fb61745d174c77b19a0d7ca3bd14992 (package MIT metadata agrees; exact v0.21.4 Git tag unavailable). Python archiver uses only standard library.
9 changes: 9 additions & 0 deletions docs/evidence/T15.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# T15 — pinned CI and deterministic production packaging

Added least-privilege GitHub Actions (contents:read, no persisted checkout credentials, pinned official action commits, exact Node/pnpm, advisory check, frozen install, official Playwright/system libraries, full verify, package). Synthetic failure artifacts retain three days; production/timing artifacts seven. Completion helper waits for reported CI checks before PR merge. No new npm dependency/version; audit recheck: 324 locked entries, zero known advisories. .npmrc caches now use portable relative locations; lockfile unchanged.

Production builds inspect actual retained module graphs and emit six runtime package/version/SPDX inventories/full licenses. WXT/browser omitted package licenses restored from pinned official project MIT text; provenance/source limitation recorded in notices/ADR003. Standard-library Python archiver includes project/third-party notices, installation docs, unminified axe source form; sorted files/fixed timestamps/permissions/stored ZIP entries avoid compression variations. Rejects wrong permissions/host/static/external configuration, test hook, source maps and dynamic/remote executable code. No public release/store publish/tag.

Fresh source checkout from staged Git tree in /tmp/accesslab-clean-T15: frozen install exit 0 with supply-chain policy/integrity checks intact; full pnpm verify exit 0 (lint/types/build, 21 unit, 4 component, 32 loaded-extension, 3 visual). Production native Chrome browser-level Extensions.triggerAction actually grants activeTab with no host permissions, runs bundled audit, handles chrome://settings restriction and clears badge/title on successful retry; test activation hook absent. Agent opened artifacts/production-action.png: normal panel/audit result, host layout preserved, synthetic input remains outside report fields. Current stable Chrome human walkthrough still pending.

Fresh checkout package and two repeated local packages byte-identical, cmp exit 0. ZIP has 22 files, SHA-256 c654c1316599476bee17808a4cc5ae34f1d767396021a5452993770caec6a7f9. Artifact dist/accesslab-0.1.0-chrome.zip and checksum ignored in Git. Clean logs in /tmp/accesslab-T15-clean-verify.log and package log. Remote CI pending at PR creation; actual result/run URL will be recorded after completion, never inferred from workflow presence.
21 changes: 21 additions & 0 deletions docs/licenses/WXT-MIT.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2023 Aaron

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
11 changes: 11 additions & 0 deletions docs/packaging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Verification and production packaging

Use Node 24.19.0, pnpm 11.19.0 and Python 3. Run `pnpm install --frozen-lockfile`, `pnpm exec playwright install chromium`, `pnpm verify`, then `pnpm package`. On a fresh Linux runner, `pnpm exec playwright install --with-deps chromium` installs required signed system libraries too. Review dependency identity, scripts, integrity/provenance and advisories before adding/updating packages (ADR 003); keep TLS, signatures and integrity checks enabled. `pnpm audit` reports known advisories, not a guarantee of uncompromised dependencies.

`pnpm build` writes the production extension to apps/extension/.output/chrome-mv3. `pnpm build:test` writes a separate localhost-only automation build to .output-test, with a worker-only activation hook. Never distribute .output-test. Production action testing uses Chrome's native browser-level Extensions.triggerAction capability, on an isolated test browser with its explicit debugging flag; the shipped artifact has no such flag or test hook.

`pnpm package` checks the production manifest's three permissions (activeTab, scripting, storage), rejects broad permissions/static scripts/external connections, checks for test activation and dynamic/remote executable code, and creates dist/accesslab-0.1.0-chrome.zip plus .zip.sha256. It includes installation instructions, local docs, actual bundled-package inventories and full licenses, project MIT notice and axe-core source form. Extract, enable Chrome Developer mode and Load unpacked from the extracted directory. This creates no store publication or public release.

Archives use sorted files, fixed timestamps/permissions and stored ZIP entries, so the archiver does not depend on compression-library variations. Locked build output must also be identical: compare hashes across repeated builds and clean source checkouts. The checksum file uses standard SHA-256 format (`sha256sum -c accesslab-0.1.0-chrome.zip.sha256` from dist/). Output is ignored in Git.

The GitHub workflow uses official actions pinned by commit, Node/pnpm pins, frozen install, official Playwright browser installation, all gates and packaging. Token permission is contents:read, checkout credentials are not retained, runs cancel superseded revisions, synthetic failure artifacts expire after three days and production artifacts after seven. No secrets, deployment, release tag or upload to a store is required. T15 evidence records actual local/remote outcomes; a configured workflow alone is not proof of a successful remote run.
6 changes: 3 additions & 3 deletions docs/tasks/status.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,9 @@
"dependencies": [
"T14"
],
"state": "pending",
"state": "done",
"acceptanceCriteria": "AccessLab-Implementation-Plan.md section 9 T15",
"commit": null,
"commit": "892847b8f7b7e27288f885be2dd9b6419c88d79e",
"evidencePath": "docs/evidence/T15.md",
"blockedReason": null,
"humanGates": []
Expand Down Expand Up @@ -209,5 +209,5 @@
"humanGates": []
}
],
"checkpoint": "T14 verified. Next task in dependency order."
"checkpoint": "T15 verified. Next task in dependency order."
}
12 changes: 11 additions & 1 deletion scripts/complete-task.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import sys,json,subprocess
import sys,json,subprocess,time
from pathlib import Path
id=sys.argv[1]; summary=sys.argv[2]
s=json.loads(Path('docs/tasks/status.json').read_text());t=next(x for x in s['tasks'] if x['id']==id)
Expand All @@ -17,6 +17,16 @@
print(pr.stdout,pr.stderr)
if pr.returncode==0:
url=pr.stdout.strip().splitlines()[-1]
if Path('.github/workflows/verify.yml').exists():
for attempt in range(12):
result=subprocess.run(['gh','pr','view',url,'--json','statusCheckRollup'],capture_output=True,text=True)
try: checks=json.loads(result.stdout).get('statusCheckRollup',[])
except json.JSONDecodeError: checks=[]
if isinstance(checks,list) and any(c.get('name')=='verify' and c.get('workflowName')=='Verify and package' for c in checks): break
time.sleep(5)
else: raise RuntimeError('CI checks not reported; investigate workflow scheduling before merge')
with open(f'/tmp/accesslab-{id}-pr-checks.log','w') as log:
subprocess.run(['gh','pr','checks',url,'--watch','--interval','10'],stdout=log,stderr=subprocess.STDOUT,check=True)
subprocess.run(['gh','pr','merge',url,'--merge'],check=True)
subprocess.run(['git','fetch','origin','main'],check=True)
subprocess.run(['git','merge','--ff-only','origin/main'],check=True)
Expand Down
4 changes: 4 additions & 0 deletions scripts/package.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { spawnSync } from 'node:child_process';
const result=spawnSync('python3',['-I','scripts/package.py'],{stdio:'inherit'});
if(result.error)throw result.error;
process.exit(result.status??1);
34 changes: 34 additions & 0 deletions scripts/package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Create a deterministic production archive without third-party packaging tools."""
from pathlib import Path
import json,re,hashlib,zipfile
root=Path(__file__).resolve().parent.parent
build=root/'apps/extension/.output/chrome-mv3'
manifest=json.loads((build/'manifest.json').read_text())
assert manifest['manifest_version']==3
assert sorted(manifest['permissions'])==['activeTab','scripting','storage']
assert not any(manifest.get(key) for key in ['host_permissions','optional_host_permissions','content_scripts','externally_connectable','web_accessible_resources','optional_permissions'])
assert manifest['background']=={'service_worker':'background.js'}
assert manifest['action']=={'default_title':'Open AccessLab'}
files={p.relative_to(build).as_posix():p.read_bytes() for p in build.rglob('*') if p.is_file()}
assert files and not any(name.endswith('.map') for name in files)
for name,data in files.items():
if name.endswith('.js'):
code=data.decode()
assert '__accesslabTestActivate' not in code, 'Test activation in production'
assert not re.search(r'\beval\s*\(|\bnew\s+Function\b|\bimport\s*\(\s*[\'\"]https?://',code), 'Dynamic/remote executable code'
if name.endswith('.html'):
assert not re.search(r'<script[^>]*src=[\'\"]https?://',data.decode()), 'Remote script'
for name in ['LICENSE','THIRD_PARTY_NOTICES.md']:
files[name]=(root/name).read_bytes()
for label,path in {'axe-core-MPL-2.0.txt':'node_modules/axe-core/LICENSE','React-MIT.txt':'node_modules/react/LICENSE','ReactDOM-MIT.txt':'node_modules/react-dom/LICENSE','WXT-MIT.txt':'docs/licenses/WXT-MIT.txt','Colorspacious-MIT.txt':'docs/licenses/colorspacious-MIT.txt','axe-core-source.txt':'node_modules/axe-core/axe.js'}.items():
files['licenses/'+label]=(root/path).read_bytes()
files['INSTALL.txt']=b'AccessLab 0.1 private engineering beta. Extract this archive, open chrome://extensions, enable Developer mode, choose Load unpacked, and select this directory. Activate on an ordinary HTTP(S) page using the toolbar action. No automatic activation or all-site access. Read the packaged docs.html and project release checklist. Axe-core source and licenses are included under licenses/.\n'
dist=root/'dist';dist.mkdir(exist_ok=True)
archive=dist/f"accesslab-{manifest['version']}-chrome.zip"
with zipfile.ZipFile(archive,'w',compression=zipfile.ZIP_STORED) as out:
for name,data in sorted(files.items()):
info=zipfile.ZipInfo(name,(1980,1,1,0,0,0));info.create_system=3;info.external_attr=0o100644<<16;info.compress_type=zipfile.ZIP_STORED
out.writestr(info,data)
digest=hashlib.sha256(archive.read_bytes()).hexdigest()
checksum=archive.with_suffix('.zip.sha256');checksum.write_text(f'{digest} {archive.name}\n')
print(f'{archive}: {len(files)} files; SHA-256 {digest}')
Loading
Loading