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
24 changes: 24 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ permissions:
contents: read

jobs:
# Lint and dependency audit run once on a current Node (ESLint 10 needs >=20.19).
quality:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 22.x
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Dependency audit
run: npm run audit:prod

test:
runs-on: ubuntu-latest
timeout-minutes: 15
Expand All @@ -31,6 +51,10 @@ jobs:
run: npm run validate:version -- "v$(node -p "require('./manifest.json').version")"
- name: Build release package
run: npm run package -- --output "$RUNNER_TEMP/IntentLock-ci.zip"
- name: Check release package contents
run: |
unzip -l "$RUNNER_TEMP/IntentLock-ci.zip" | grep -q manifest.json
! unzip -l "$RUNNER_TEMP/IntentLock-ci.zip" | grep -E 'legacy-extension-source|tests/|docs/|node_modules|\.env'

# Real Chromium journeys load the unpacked MV3 extension against synthetic
# localhost fixtures only; no secrets, no network beyond installation.
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ xcuserdata/
data/app-state.json
data/app-state.json.lock
storage/logs/
# macOS Finder / sync-conflict duplicates such as "background 2.js"
* [0-9].*
* [0-9]
25 changes: 25 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Architecture (summary)

Full detail lives in [`docs/architecture.md`](docs/architecture.md); storage layout in
[`docs/storage-schema.md`](docs/storage-schema.md); heuristics in [`docs/heuristic-policy.md`](docs/heuristic-policy.md).

## Components

| Piece | Files | Role |
| --- | --- | --- |
| Service worker | `background.js` | Owns session state, drift pipeline, alarms, history retention. Terminated when idle; state is restored from `storage.local` and alarms on wake. |
| Content scripts | `page-tracker.js`, `intervention-overlay.js`, `content.js` | Report dwell, render the lock overlay. Untrusted page context: they can only send an allowlisted set of messages. |
| Extension pages | `newtab`, `popup`, `options`, `history`, `analytics`, `diagnostics`, `intervention` | UI. Only these may mutate session or settings state. |
| Drift logic | `heuristic-policy.js`, `drift.js`, `drift-cache.js` | Local rules decide locks; optional LLM verdict is a second opinion. |
| LLM | `providers.js`, `llm.js`, `llm-backoff.js` | Calls only the user-selected provider with minimized context. |
| Storage | `storage-authority.js`, `storage-client.js`, `storage-queue.js`, `privacy-utils.js` | Epoch-guarded serialized writes, sanitization, redaction. |
| Diagnostics | `error-log.js`, `diagnostics.*` | Local, redacted, bounded error log with manual copy/export. |

## Trust boundaries

page -> content script -> (allowlisted messages, sender.tab required) -> service worker -> `storage.local` (state) / `storage.session` (API key) -> optional HTTPS call to the chosen LLM provider.

## Legacy snapshot

`legacy-extension-source/` is an old v1.4.1 copy of the extension kept for history only. It is not
loaded, packaged, linted or tested. See `docs/PRODUCTION_READINESS.md` (Blocked on owner) for the removal proposal.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
- Sessions not ended within 24 hours end automatically; history and diagnostics are pruned in storage at startup and hourly.

### Added
- ESLint (`npm run lint`), `npm run audit:prod`, `npm run check`, and a CI quality job (lint + dependency audit); package contents are checked in CI.
- `SECURITY.md`, `CONTRIBUTING.md`, root `ARCHITECTURE.md`, and `tests/store-readiness.test.mjs` (manifest permissions, CSP, listing justifications and packaged files stay in sync).
- Pause timer / Resume timer on the new-tab session view. Paused time does not count toward dwell or the time budget, no dwell lock fires while paused, and the state survives worker restarts.

### Changed
Expand Down
40 changes: 40 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Contributing

IntentLock is a no-build Chrome MV3 extension. See `docs/development.md` for loading it unpacked
and `ARCHITECTURE.md` for the process model.

## Setup

```bash
npm ci
```

## Checks (run before opening a PR)

```bash
npm run lint # ESLint
npm test # unit + integration (node:test)
npm run verify:static # manifest, CSP, runtime references
npm run audit:prod # npm audit
npm run package -- --output /tmp/IntentLock-check.zip # deterministic release zip
npm run test:browser # optional: real Chromium journeys (needs npx playwright install chromium)
```

`npm run check` runs lint, tests, static verification and version validation together.

## Rules

- Keep behavior backward compatible; do not add telemetry or new outbound network calls.
- A new permission or host pattern needs a justification in `store/LISTING.md`,
`docs/privacy-policy.md` and the README Permissions section.
- Never store the API key outside `chrome.storage.session`, and never log it.
- New files that must ship go in `RUNTIME_FILES` in `scripts/package-release.mjs`.
- No personal paths, emails or secrets in committed files.

## Releasing (maintainer only)

1. Bump `version` in `manifest.json` (and `package.json` if desired); move `CHANGELOG.md`
`[Unreleased]` entries under the new version heading.
2. `npm run check` and `npm run package`.
3. Merge to `main`, then the maintainer tags `vX.Y.Z`; the Release workflow validates the tag against
the manifest and attaches the zip. Chrome Web Store submission is a manual step using `store/LISTING.md`.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,10 @@ Quick start:
# After content.js change: also reload the affected tab
```

### Contributing and security

See [CONTRIBUTING.md](CONTRIBUTING.md) (checks, release steps), [SECURITY.md](SECURITY.md) (reporting and security model) and [ARCHITECTURE.md](ARCHITECTURE.md). `npm run check` runs lint, tests, static verification and version validation.

### Version history

See [CHANGELOG.md](CHANGELOG.md).
Expand Down
44 changes: 44 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Security Policy

## Reporting a vulnerability

Please report suspected vulnerabilities privately through GitHub's
**Security > Report a vulnerability** (private advisory) on this repository.
Do not open a public issue with exploit details, and never include a real API key in a report.

You can expect an acknowledgement, a fix or mitigation plan, and credit if you want it.
This is a single-maintainer project, so response times are best effort.

## Supported versions

Only the latest release on `main` is supported.

## Security model

- **Permissions (least privilege):** `tabs`, `storage`, `idle`, `tabGroups`, `alarms`, plus
`http://*/*` and `https://*/*` host access. Each is justified in `store/LISTING.md` and a test
(`tests/store-readiness.test.mjs`) fails if the manifest and the listing drift apart.
Host access is required because the drift overlay is injected into whatever page the user is on.
- **CSP:** extension pages use `script-src 'self'; object-src 'self'`. No remote code, no inline
script, no `eval`.
- **API key:** stored only in `chrome.storage.session` (cleared when the browser closes, never
written to `storage.local`, never synced). If session storage is unavailable the key cannot be
saved and AI checks stay off. The key is removed when the provider or endpoint changes, so it is
never sent to a destination the user did not choose. It is only sent to the provider the user selected.
- **Logging:** error logs and the diagnostics export pass through `redactSecrets`; page titles,
paths, queries and fragments are never stored. Logs stay on the device.
- **Message passing:** the service worker accepts only known message types, rejects senders with
another extension id, restricts content scripts to a small allowlist
(`CONTENT_EVENT`, `GET_INTERVENTION_STATE`, `INTERVENTION_TRANSITION`, `LOG_ERROR`),
and requires a storage epoch on every mutation so stale writers cannot resurrect deleted data.
- **No telemetry:** the only outbound requests are LLM calls to the provider the user configured.

## Dependencies

The extension has no runtime dependencies. Dev dependencies (Playwright, ESLint) are pinned
exactly; `npm run audit:prod` runs `npm audit` and CI runs it on every push.

## Secrets hygiene

Never commit keys, tokens, `.env` files, personal paths or browser profiles.
Tests use obviously fake keys only.
5 changes: 2 additions & 3 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { checkDriftLLM } from './llm.js';
import { clearDriftCache } from './drift-cache.js';
import { logError, registerErrorLogAuthority } from './error-log.js';
import { getEffectiveDistractionSites, DEFAULT_DISTRACTION_SITES } from './distraction-sites.js';
import { clearLlmBackoff, getQuotaBackoffUntil, registerBackoffCallback, setQuotaBackoff } from './llm-backoff.js';
import { clearLlmBackoff, registerBackoffCallback, setQuotaBackoff } from './llm-backoff.js';
import {
applyDwellDelta,
computeOnIntentRatio,
Expand All @@ -26,7 +26,6 @@ import {
import {
sanitizeSessionHistory,
sanitizeUrl,
hostnameFromUrl,
pruneByRetention,
redactSecrets,
SESSION_RETENTION_MS,
Expand Down Expand Up @@ -1480,7 +1479,7 @@ function triggerIntervention(reason, tabId = null) {
const session = result.activeSession;
if (!session?.isActive) return null;

let targetTab = null;
let targetTab;
if (Number.isInteger(tabId)) {
targetTab = await getTab(tabId);
} else {
Expand Down
59 changes: 59 additions & 0 deletions docs/PRODUCTION_READINESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Production readiness

Audit date: 2026-10-12. Scores are 0-10; 10 only if verifiably complete. Nothing here has been
submitted to the Chrome Web Store; the Web Store listing, screenshots and publishing remain owner actions.

## Baseline (before this pass)

| # | Dimension | Score | Evidence / gap |
|---|-----------|-------|----------------|
| 1 | Build and run reproducible | 8 | No build step; `package-lock.json`, deterministic zip script. No single `check` command. |
| 2 | Tests | 8 | 361 node tests (drift, heuristics, privacy, lifecycle, storage) plus Playwright journeys. No store/manifest consistency test. |
| 3 | Static quality | 3 | No ESLint, Prettier or type checking. |
| 4 | Security | 8 | Session-only API key, tight CSP, sender-validated messaging, 0 audit findings, no secrets found. Gap: no SECURITY.md, audit not in CI. |
| 5 | Reliability | 8 | Epoch-guarded storage, alarms, retention pruning, malformed-storage tests. Browser lifecycle tests exist but were not run in this audit. |
| 6 | Config and packaging | 7 | Explicit-allowlist zip, version validation, CHANGELOG, listing text. Screenshots missing (human capture required); Unreleased changelog not versioned. |
| 7 | CI | 6 | test.yml (Node 18/20/22 + browser), release.yml, pages.yml. No lint, no audit. Actions currently failing account-wide (billing), so unverified remotely. |
| 8 | Docs | 7 | README, AGENTS.md, docs/architecture.md, privacy policy and page. Missing SECURITY.md, CONTRIBUTING.md, root ARCHITECTURE.md. |
| 9 | UX / accessibility / performance | 7 | ARIA roles, focus outlines, labelled controls present. No documented contrast or idle-CPU measurement. |
| 10 | Operability | 8 | Local redacted error log, diagnostics page with copy/clear. No documented diagnostics privacy guarantees. |

Other findings: personal-data scan of tracked files found none (only fake test keys); stray untracked
macOS duplicates ("name 2.ext") existed in the local clone and are now ignored via `.gitignore`.

## After (this pass)

| # | Dimension | Before | After | What changed / what still caps it |
|---|-----------|--------|-------|-----------------------------------|
| 1 | Build and run | 8 | 9 | `npm run check` (lint, tests, static, version). Not verified on Node 18 locally. |
| 2 | Tests | 8 | 8 | +7 store-readiness tests (368 pass). Playwright journeys not run in this pass; no coverage measurement. |
| 3 | Static quality | 3 | 6 | ESLint 10 clean (0 errors, 0 warnings), CI-enforced. No formatter or type checking (would require a repo-wide reformat or TS migration). |
| 4 | Security | 8 | 9 | SECURITY.md, audit in CI, permission/CSP tests tied to the listing. Capped: no third-party review. |
| 5 | Reliability | 8 | 8 | Unchanged; already strong. Lifecycle browser tests need a run on the owner's machine/CI. |
| 6 | Config and packaging | 7 | 8 | Zip contents checked in CI and tests. Capped: no screenshots, changelog still Unreleased. |
| 7 | CI | 6 | 7 | Lint and audit job added. Cannot be verified remotely (Actions billing failure). |
| 8 | Docs | 7 | 9 | SECURITY, CONTRIBUTING, ARCHITECTURE, release steps. |
| 9 | UX / a11y / perf | 7 | 7 | Not changed; no measured contrast or idle-CPU results. |
| 10 | Operability | 8 | 8 | Unchanged. |

## Verification (local)

`npm run lint`, `npm test` (368 pass), `npm run verify:static` (74 pass), `npm audit` (0 vulnerabilities),
`npm run package` (44-file zip, no legacy/docs/tests/env entries), `npm run validate:version`.

## Branches and legacy code (not deleted)

- `feat/post-merge-remediation` (32 unmerged commits) and `gh-pages` are untouched; review before any cleanup.
- `legacy-extension-source/` is an old v1.4.1 copy of the extension. Nothing references it, it is not
packaged, linted or tested (ESLint ignores it), and the current source has superseded it. It is dead code.

## Blocked on owner

1. Capture the five 1280x800 Web Store screenshots listed in `store/screenshots/README.md`.
2. Submit to the Chrome Web Store, tag `vX.Y.Z` and cut the release (intentionally not done).
3. Fix GitHub Actions billing so the new quality job and existing workflows can run; re-verify.
4. Decide on removing `legacy-extension-source/` (proposal: `git rm -r`; history keeps it) and on the
32-commit `feat/post-merge-remediation` and `gh-pages` branches.
5. Run `npm run test:browser` (needs `npx playwright install chromium`) and the manual acceptance checklist.
6. Optional: adopt Prettier/TypeScript checking (large diff), enable GitHub private vulnerability reporting
(SECURITY.md assumes it), measure contrast and idle CPU.
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ npm run package

The Node suites use built-in `node:test` + `assert/strict`; `npm test` needs no
dependency install and does not discover browser tests. CI (`.github/workflows/test.yml`)
runs `npm test`, `npm run verify:static`, version validation and a package build on
runs lint and `npm audit` (quality job), `npm test`, `npm run verify:static`, version validation and a package build on
Node 18/20/22, plus a separate `browser` job that installs the pinned Playwright and
Chromium and runs the real journeys with the browser sandbox enabled. Locally, run the
real browser suite separately:
Expand Down
29 changes: 29 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import js from '@eslint/js';
import globals from 'globals';

// Extension sources run as browser/service-worker modules or classic content
// scripts; tests, scripts and config run in Node.
export default [
{ ignores: ['node_modules/**', 'dist/**', 'legacy-extension-source/**', 'test-results/**', 'playwright-report/**'] },
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 2024,
sourceType: 'module',
globals: { ...globals.browser, ...globals.serviceworker, chrome: 'readonly' },
},
rules: {
'no-unused-vars': ['warn', { args: 'none', caughtErrors: 'none' }],
'no-var': 'error',
eqeqeq: ['error', 'always', { null: 'ignore' }],
},
},
{
files: ['content.js', 'page-tracker.js', 'intervention-overlay.js'],
languageOptions: { sourceType: 'script' },
},
{
files: ['tests/**/*.mjs', 'scripts/**/*.mjs', '*.mjs'],
languageOptions: { globals: { ...globals.node } },
},
];
Loading
Loading