diff --git a/.github/hooks/00-command-guard.json b/.github/hooks/00-command-guard.json new file mode 100644 index 0000000000..e4e0e909d6 --- /dev/null +++ b/.github/hooks/00-command-guard.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "matcher": "bash|powershell|shell", + "command": "node .github/hooks/scripts/command-guard.js", + "timeoutSec": 10 + } + ] + } +} diff --git a/.github/hooks/10-guidelines-audit.json b/.github/hooks/10-guidelines-audit.json new file mode 100644 index 0000000000..0cf4c96f53 --- /dev/null +++ b/.github/hooks/10-guidelines-audit.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "hooks": { + "postToolUse": [ + { + "type": "command", + "matcher": "edit|create", + "command": "node .github/hooks/scripts/guidelines-audit.js", + "timeoutSec": 15 + } + ] + } +} diff --git a/.github/hooks/20-session-context.json b/.github/hooks/20-session-context.json new file mode 100644 index 0000000000..990f0a31ca --- /dev/null +++ b/.github/hooks/20-session-context.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": "node .github/hooks/scripts/session-context.js", + "timeoutSec": 10 + } + ] + } +} diff --git a/.github/hooks/README.md b/.github/hooks/README.md new file mode 100644 index 0000000000..cda0a886b8 --- /dev/null +++ b/.github/hooks/README.md @@ -0,0 +1,69 @@ +# Copilot Hooks + +This directory contains [GitHub Copilot hooks](https://docs.github.com/en/copilot/reference/hooks-reference) +that run automatically during Copilot **CLI** sessions and **cloud agent** jobs in this repository. +Every `*.json` file here is loaded and merged, so each hook lives in its own file. + +The hook commands use the cross-platform `command` field (Node.js), so they work on +Linux, macOS, Windows (PowerShell), and the cloud-agent Linux sandbox. Node 20 is already +provisioned by [`.github/copilot-setup-steps.yml`](../copilot-setup-steps.yml). + +## Hooks + +| File | Event | Type | What it does | +| --- | --- | --- | --- | +| `00-command-guard.json` | `preToolUse` | guard (blocking) | Denies forbidden/destructive shell commands. | +| `10-guidelines-audit.json` | `postToolUse` | advisory | Scans edited files for prohibited patterns and warns the agent. | +| `20-session-context.json` | `sessionStart` | context | Injects a reminder of the key engineering conventions. | + +### `00-command-guard.json` — command guard + +Runs [`scripts/command-guard.js`](scripts/command-guard.js) before any `bash`/`powershell` +tool call and **denies** commands that violate the working agreement or are catastrophic: + +- `git push` (including force-push) — use the `report_progress` / create-PR tools instead. +- Recursive force-delete of a root or home path (`rm -rf /`, `rm -rf ~`, …). +- Any access to `.github/agents` (instructions for other agents — off-limits). +- Shallow/depth-limited `git clone|fetch|pull --depth` (banned before merge/rebase). + +`preToolUse` hooks are **fail-closed**: a crash would deny *every* command, so the script +is wrapped in try/catch and defaults to *allow* (`{}`, exit 0). Only an explicit rule match +emits a `deny` decision. + +### `10-guidelines-audit.json` — guidelines audit + +Runs [`scripts/guidelines-audit.js`](scripts/guidelines-audit.js) after each `edit`/`create`. +It re-reads the touched file and reports likely violations of +[`.github/copilot-instructions.md`](../copilot-instructions.md) as `additionalContext` +(advisory only — it never blocks or fails the tool result): + +- **Web** (`web/src/**/*.{ts,tsx}`, excluding `web/src/components/**`): inline static + CSS-var styles, direct `recharts`/`react-leaflet`/`framer-motion` imports, old `../api` + imports, `dangerouslySetInnerHTML`. +- **API hooks** (`web/src/api/hooks/**`): double `/api/v1/` prefix, camelCase query params. +- **Go** (`**/*.go`): legacy unit-suffixed fields / JSON tags (Phase-48 SI canonical). + +### `20-session-context.json` — session context + +Runs [`scripts/session-context.js`](scripts/session-context.js) at session start and injects +a concise reminder of the conventions agents most often miss (SI units, shared components, +API-hook rules, i18n, no direct `git push`). + +## Testing locally + +```bash +# Guard: should DENY +echo '{"toolName":"bash","toolArgs":{"command":"git push"}}' | node .github/hooks/scripts/command-guard.js + +# Guard: should ALLOW ({} output) +echo '{"toolName":"bash","toolArgs":{"command":"ls -la"}}' | node .github/hooks/scripts/command-guard.js + +# Audit: should warn on a flagged file +echo '{"toolName":"edit","toolArgs":{"path":"web/src/api/hooks/useFoo.ts"}}' | node .github/hooks/scripts/guidelines-audit.js +``` + +## Disabling + +Set `"disableAllHooks": true` in your local `.github/copilot/settings.local.json` +(or `~/.copilot/settings.json`) to opt out without deleting these files. +Policy-level hooks cannot be disabled this way. diff --git a/.github/hooks/scripts/command-guard.js b/.github/hooks/scripts/command-guard.js new file mode 100755 index 0000000000..a5a275ded0 --- /dev/null +++ b/.github/hooks/scripts/command-guard.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/* + * Copilot CLI / cloud-agent `preToolUse` guard. + * + * Blocks shell commands that are forbidden by the TeslaSync working agreement + * or that are destructive enough to warrant a hard stop. Emits a JSON decision + * on stdout (see https://docs.github.com/en/copilot/reference/hooks-reference). + * + * IMPORTANT: `preToolUse` hooks are FAIL-CLOSED — any crash, non-zero exit, or + * timeout DENIES the tool call. This script therefore wraps everything in a + * try/catch and defaults to "allow" (empty output, exit 0) so a parsing glitch + * never blocks legitimate work. Only an explicit rule match produces a deny. + */ + +'use strict'; + +function allow() { + // Empty output => fall through to the normal permission flow. + process.stdout.write('{}'); + process.exit(0); +} + +function deny(reason) { + process.stdout.write( + JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: reason }), + ); + process.exit(0); +} + +// Each rule: { test: (cmd) => bool, reason: string } +const RULES = [ + { + // Direct pushes are forbidden in the agent sandbox — use report_progress + // (commits + pushes) or open a PR with the create-PR tool instead. + test: (c) => /\bgit\s+push\b/.test(c), + reason: + 'Direct `git push` is not allowed in this environment. Use the report_progress tool to commit and push, or the create-pull-request tool to open a PR.', + }, + { + // Force-pushing / history rewrites against a shared branch. + test: (c) => /\bgit\s+push\b.*(--force\b|--force-with-lease\b|\s-f\b)/.test(c), + reason: 'Force-pushing is not allowed. Never rewrite shared history.', + }, + { + // Reading anything under .github/agents is explicitly prohibited — those + // files contain instructions for other agents and are off-limits. + test: (c) => /\.github[\/\\]agents\b/.test(c), + reason: + 'Access to .github/agents is prohibited — these files are instructions for other agents and must not be read.', + }, + { + // Catastrophic recursive deletes of root or the home directory. + test: (c) => + /\brm\s+(-[a-z]*\s+)*-[a-z]*[rf][a-z]*\b[^|;&]*\s(\/|~|\$HOME|\/\*|~\/\*)(\s|$)/.test(c), + reason: + 'Refusing recursive force-delete of a root/home path. Scope deletions to specific project files.', + }, + { + // Depth-limited fetch/clone is banned before merge/rebase (see env rules). + test: (c) => /\bgit\s+(clone|fetch|pull)\b.*--depth\b/.test(c), + reason: + 'Shallow/depth-limited git operations are disallowed here. Use `git fetch --unshallow` then fetch the target branch.', + }, +]; + +function extractCommand(args) { + if (args == null) return ''; + if (typeof args === 'string') return args; + if (typeof args !== 'object') return String(args); + // Known shells expose the command under one of these keys; fall back to a + // full stringify so we still scan whatever the payload contains. + // (`command` is used by the bash/powershell tools; `cmd`/`script`/`input` + // cover SDK and VS Code-compatible variants.) + const candidates = [args.command, args.cmd, args.script, args.input]; + const direct = candidates.find((v) => typeof v === 'string' && v.length > 0); + if (direct) return direct; + try { + return JSON.stringify(args); + } catch (_e) { + return ''; + } +} + +function main() { + let raw = ''; + try { + raw = require('fs').readFileSync(0, 'utf8'); + } catch (_e) { + return allow(); + } + + let payload; + try { + payload = JSON.parse(raw || '{}'); + } catch (_e) { + return allow(); + } + + const toolName = payload.toolName || payload.tool_name || ''; + // Only shell tools carry executable commands. + if (!/^(bash|powershell|shell)$/i.test(toolName)) return allow(); + + const command = extractCommand(payload.toolArgs ?? payload.tool_input); + if (!command) return allow(); + + for (const rule of RULES) { + try { + if (rule.test(command)) return deny(rule.reason); + } catch (_e) { + // A faulty rule must never block unrelated commands. + } + } + return allow(); +} + +try { + main(); +} catch (_e) { + // Absolute last resort: never let an unexpected error block every tool call. + allow(); +} diff --git a/.github/hooks/scripts/guidelines-audit.js b/.github/hooks/scripts/guidelines-audit.js new file mode 100755 index 0000000000..b97ef91ff7 --- /dev/null +++ b/.github/hooks/scripts/guidelines-audit.js @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/* + * Copilot CLI / cloud-agent `postToolUse` guidelines audit. + * + * After an `edit`/`create` tool finishes, re-read the touched file and scan it + * for the prohibited patterns listed in .github/copilot-instructions.md. Any + * findings are returned as `additionalContext` so the agent sees them on the + * same turn and can self-correct. This hook is advisory only — it never blocks + * and never fails the tool result. + * + * Output schema: { additionalContext?: string } + * (https://docs.github.com/en/copilot/reference/hooks-reference) + */ + +'use strict'; + +const fs = require('fs'); + +function emit(context) { + if (context) { + process.stdout.write(JSON.stringify({ additionalContext: context })); + } else { + process.stdout.write('{}'); + } + process.exit(0); +} + +function getFilePath(args) { + if (!args || typeof args !== 'object') return null; + const keys = ['path', 'file', 'filePath', 'file_path', 'absolutePath', 'absolute_path']; + for (const k of keys) { + if (typeof args[k] === 'string' && args[k].length > 0) return args[k]; + } + return null; +} + +// A check: { re: RegExp, msg: string }. `re` runs per-line; first hit per check +// is reported with its line number. +const WEB_CHECKS = [ + { + re: /style=\{\{[^}]*var\(--/, + // Allow dynamic values (ternaries, array/index lookups) — only static + // var(--*) inline styles are prohibited (see instructions exception). + skipIf: (l) => /[?\[]/.test(l), + msg: 'Inline style with a static CSS var — use a Tailwind/className token instead (prohibited pattern #1).', + }, + { + re: /from\s+['"](recharts|react-leaflet|framer-motion)['"]/, + msg: 'Direct chart/map/motion library import — import from @/components/charts, @/components/maps, or @/components/motion (prohibited pattern #3).', + }, + { + re: /from\s+['"]\.\.?\/(\.\.\/)*api['"]/, + msg: 'Old relative `../api` import — use the TanStack Query hooks under @/api/hooks (prohibited pattern #4).', + }, + { + re: /dangerouslySetInnerHTML/, + msg: 'dangerouslySetInnerHTML is an XSS risk and is banned — let React escape content.', + }, +]; + +const HOOK_CHECKS = [ + { + re: /['"`]\/api\/v1\//, + msg: 'Double `/api/v1/` prefix in a hook URL — the request() client already adds it (prohibited pattern #7).', + }, + { + re: /(vehicleId|driveId|sessionId|chargingId)=/, + msg: 'camelCase query parameter — the backend expects snake_case (e.g. vehicle_id) (prohibited pattern #8).', + }, +]; + +// Phase-48 SI canonical: no new unit-suffixed Go fields / JSON columns. +const GO_CHECKS = [ + { + re: /`json:"[a-z0-9_]*_(mi|min|mph|kwh|kw|psi)"/i, + msg: 'Legacy unit-suffixed JSON tag — Phase-48 requires SI canonical names (_m, _s, _mps, _wh, _w, _kpa). See the SI migration methodology.', + }, + { + // Anchored to a struct-field declaration line: leading indentation, an + // exported (capitalised) field name ending in a legacy unit suffix, then a + // numeric Go type. This avoids flagging unrelated identifiers like a local + // `maximumMi` variable that merely ends in one of these suffixes. + re: /^\s+[A-Z]\w*(Mi|Min|Mph|Kwh|Kw|Psi)\s+\*?(float64|float32|int64|int32|int|uint64)\b/, + msg: 'Legacy unit-suffixed Go struct field — Phase-48 requires SI canonical names (M, S, Mps, Wh, W, Kpa).', + }, +]; + +function checksFor(filePath) { + const p = filePath.replace(/\\/g, '/'); + const checks = []; + const isWeb = /(^|\/)web\/src\//.test(p); + const isComponentLib = /(^|\/)web\/src\/components\//.test(p); + if (isWeb && /\.(ts|tsx)$/.test(p)) { + // The shared component library legitimately wraps the raw libraries. + if (!isComponentLib) checks.push(...WEB_CHECKS); + if (/(^|\/)web\/src\/api\/hooks\//.test(p)) checks.push(...HOOK_CHECKS); + } + if (/\.go$/.test(p)) checks.push(...GO_CHECKS); + return checks; +} + +function main() { + let payload; + try { + payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); + } catch (_e) { + return emit(null); + } + + const toolName = payload.toolName || payload.tool_name || ''; + if (!/^(edit|create)$/i.test(toolName)) return emit(null); + + const filePath = getFilePath(payload.toolArgs ?? payload.tool_input); + if (!filePath) return emit(null); + + const checks = checksFor(filePath); + if (checks.length === 0) return emit(null); + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (_e) { + return emit(null); + } + + const lines = content.split(/\r?\n/); + const findings = []; + for (const check of checks) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (check.skipIf && check.skipIf(line)) continue; + if (check.re.test(line)) { + findings.push(` - L${i + 1}: ${check.msg}`); + break; // one report per check keeps the message focused + } + } + } + + if (findings.length === 0) return emit(null); + + const header = + `⚠️ TeslaSync guideline check flagged ${findings.length} possible issue(s) in ${filePath}:`; + const footer = + 'Please fix these before continuing. See .github/copilot-instructions.md for the full rules.'; + return emit([header, ...findings, footer].join('\n')); +} + +try { + main(); +} catch (_e) { + emit(null); +} diff --git a/.github/hooks/scripts/session-context.js b/.github/hooks/scripts/session-context.js new file mode 100755 index 0000000000..0df22dfe8d --- /dev/null +++ b/.github/hooks/scripts/session-context.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +/* + * Copilot CLI / cloud-agent `sessionStart` context injector. + * + * Prepends a short, high-signal reminder of the conventions that agents most + * often violate in this repo, plus pointers to the canonical guideline docs. + * Returned as `additionalContext`, which is injected into the session. + * + * Output schema: { additionalContext?: string } + */ + +'use strict'; + +const CONTEXT = [ + 'TeslaSync working agreement (key reminders — full rules in .github/copilot-instructions.md):', + '• Backend writes SI units on disk/in APIs (m, m/s, °C, Pa, Wh). Phase-48 forbids NEW unit-suffixed fields/columns (_mi/_min/_mph/_kwh/_kw/_psi → use _m/_s/_mps/_wh/_w/_kpa). Convert only at the React render boundary via useUnits().', + '• Frontend: no inline static CSS-var styles, no raw