Skip to content
Open
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
13 changes: 13 additions & 0 deletions .github/hooks/00-command-guard.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"matcher": "bash|powershell|shell",
"command": "node .github/hooks/scripts/command-guard.js",
"timeoutSec": 10
}
]
}
}
13 changes: 13 additions & 0 deletions .github/hooks/10-guidelines-audit.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": 1,
"hooks": {
"postToolUse": [
{
"type": "command",
"matcher": "edit|create",
"command": "node .github/hooks/scripts/guidelines-audit.js",
"timeoutSec": 15
}
]
}
}
12 changes: 12 additions & 0 deletions .github/hooks/20-session-context.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": 1,
"hooks": {
"sessionStart": [
{
"type": "command",
"command": "node .github/hooks/scripts/session-context.js",
"timeoutSec": 10
}
]
}
}
69 changes: 69 additions & 0 deletions .github/hooks/README.md
Original file line number Diff line number Diff line change
@@ -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).

Comment on lines +7 to +10
## 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.
121 changes: 121 additions & 0 deletions .github/hooks/scripts/command-guard.js
Original file line number Diff line number Diff line change
@@ -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();
}
152 changes: 152 additions & 0 deletions .github/hooks/scripts/guidelines-audit.js
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading