chore: adopt guardrail scripts
This commit is contained in:
parent
d5435557e1
commit
f73796565c
@ -14,9 +14,16 @@ If you are unsure about sth, just google it.
|
||||
- `pnpm test`: Executes the full Vitest suite once.
|
||||
- `pnpm dev`: Watches and incrementally rebuilds the library with TypeScript.
|
||||
- `pnpm clean`: Removes `dist/` so you can verify fresh builds.
|
||||
- `pnpm run docs:list`: Lists required rule summaries via `scripts/docs-list.ts`; run this at the start of every session and reopen any referenced doc before writing code.
|
||||
- `tmux new-session -- pnpm mcporter:list`: Exercise the CLI in a resilient terminal; tmux makes it easy to spot stalls or hung servers.
|
||||
- `gh run list --limit 1 --watch`: Stream CI status in real time; use `gh run view --log` on the returned run id to inspect failures quickly.
|
||||
|
||||
## Guardrail Tooling (runner/git wrappers)
|
||||
- Use `./runner <command>` for every non-trivial shell command (tests, builds, npm, node, bun, etc.). The Bun-backed runner enforces timeouts, blocks risky subcommands, and keeps logs consistent. Only simple read-only tools (e.g., `cat`, `ls`, `rg`) may bypass it.
|
||||
- When you must run git, invoke it through the wrapper: `./runner git status -sb`, `./runner git diff`, or `./runner git log`. Those are the only git subcommands permitted. Never run `git push` unless the user asks explicitly, and even then go through `./runner git push`.
|
||||
- Never call `git add` / `git commit` directly. To create a commit, list the exact paths via `./scripts/committer "type: summary" path/to/file1 path/to/file2`.
|
||||
- If you need to run the Bun-based git policy helper directly, you can use `./git ...`, but prefer `./runner git ...` so logging stays uniform.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- TypeScript files use 2-space indentation, modern ES module syntax, and `strict` compiler settings.
|
||||
- Imports stay sorted logically; prefer relative paths within `src/`.
|
||||
|
||||
180
bin/git
Executable file
180
bin/git
Executable file
@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { accessSync, constants, existsSync, realpathSync } from 'node:fs';
|
||||
import { dirname, join, normalize, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import process from 'node:process';
|
||||
import { analyzeGitExecution, evaluateGitPolicies } from '../scripts/git-policy';
|
||||
|
||||
const commandArgs = ['git', ...process.argv.slice(2)];
|
||||
const context = analyzeGitExecution(commandArgs, process.cwd());
|
||||
const gitBinary = findRealGitBinary();
|
||||
if (!gitBinary) {
|
||||
console.error('Unable to locate the system git binary outside the Sweetistics git shim.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!shouldEnforcePolicies(context)) {
|
||||
process.exit(await runGitPassthrough(gitBinary));
|
||||
}
|
||||
|
||||
const evaluation = evaluateGitPolicies(context);
|
||||
|
||||
if (evaluation.requiresCommitHelper) {
|
||||
console.error(
|
||||
'Direct git add/commit is disabled. Use ./scripts/committer "chore(runner): describe change" "scripts/runner.ts" instead—see AGENTS.md and ./scripts/committer for details. The helper auto-stashes unrelated files before committing.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (evaluation.requiresExplicitConsent) {
|
||||
if (process.env.RUNNER_THE_USER_GAVE_ME_CONSENT === '1') {
|
||||
if (process.env.RUNNER_DEBUG === '1') {
|
||||
console.error('[git-shim] Proceeding with guarded git command because RUNNER_THE_USER_GAVE_ME_CONSENT=1.');
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`Using git ${context.subcommand ?? ''} requires consent. Set RUNNER_THE_USER_GAVE_ME_CONSENT=1 after verifying with the user, or ask them explicitly before proceeding.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (evaluation.isDestructive) {
|
||||
console.error(
|
||||
'Destructive git commands require explicit user consent. If you are trying to revert an individual file, carefully undo your changes from memory. Other agents work in this repository and a blanket reset would risk overriding their non-committed work.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(await runGitPassthrough(gitBinary));
|
||||
|
||||
function findRealGitBinary(): string | null {
|
||||
const scriptDir = normalize(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
const pathCandidates = (process.env.PATH ?? '')
|
||||
.split(':')
|
||||
.map((segment) => segment.trim())
|
||||
.filter((segment) => segment.length > 0);
|
||||
|
||||
for (const segment of pathCandidates) {
|
||||
const normalized = normalize(resolve(segment));
|
||||
if (normalized === scriptDir) {
|
||||
continue;
|
||||
}
|
||||
const candidate = join(normalized, 'git');
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const fallback of ['/usr/bin/git', '/usr/local/bin/git', '/opt/homebrew/bin/git']) {
|
||||
if (isExecutable(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isExecutable(candidate: string): boolean {
|
||||
if (!existsSync(candidate)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
accessSync(candidate, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runGitPassthrough(binary: string): Promise<number> {
|
||||
const proc = Bun.spawn([binary, ...process.argv.slice(2)], {
|
||||
stdin: 'inherit',
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
return proc.exited;
|
||||
}
|
||||
|
||||
function shouldEnforcePolicies(context: ReturnType<typeof analyzeGitExecution>): boolean {
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
let repoRoot: string | null = null;
|
||||
try {
|
||||
repoRoot = realpathSync(resolve(scriptDir, '..'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidatePaths = collectCandidatePaths(context);
|
||||
|
||||
for (const path of candidatePaths) {
|
||||
if (path && isPathInsideRepo(path, repoRoot)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectCandidatePaths(context: ReturnType<typeof analyzeGitExecution>): Set<string> {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
if (context.workDir) {
|
||||
candidates.add(context.workDir);
|
||||
}
|
||||
|
||||
const argv = toArray(context.invocation?.argv);
|
||||
for (let index = 1; index < argv.length; index += 1) {
|
||||
const token = argv[index];
|
||||
|
||||
if (token === '-C' || token === '--git-dir' || token === '--work-tree') {
|
||||
const value = argv[index + 1];
|
||||
if (value) {
|
||||
candidates.add(resolve(context.workDir, value));
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith('-C') && token.length > 2) {
|
||||
candidates.add(resolve(context.workDir, token.slice(2)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith('--git-dir=')) {
|
||||
candidates.add(resolve(context.workDir, token.slice('--git-dir='.length)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith('--work-tree=')) {
|
||||
candidates.add(resolve(context.workDir, token.slice('--work-tree='.length)));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function isPathInsideRepo(candidate: string, repoRoot: string): boolean {
|
||||
try {
|
||||
const realCandidate = realpathSync(candidate);
|
||||
if (realCandidate === repoRoot) {
|
||||
return true;
|
||||
}
|
||||
return realCandidate.startsWith(repoRoot.endsWith(sep) ? repoRoot : `${repoRoot}${sep}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function toArray<T>(value: T | T[] | null | undefined): T[] {
|
||||
if (value == null) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
13
runner
Executable file
13
runner
Executable file
@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Guardrail shim that wraps every command with the Bun runner; when behavior changes, record the note via ./scripts/committer \"docs: update AGENTS for runner\" \"AGENTS.md\" so fellow agents keep pace.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
echo "[runner] bun is required but was not found on PATH." >&2
|
||||
echo "[runner] Install Bun from https://bun.sh and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec bun "$ROOT_DIR/scripts/runner.ts" "$@"
|
||||
53
scripts/committer
Executable file
53
scripts/committer
Executable file
@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
# Disable glob expansion to handle brackets in file paths
|
||||
set -f
|
||||
usage() {
|
||||
printf 'Usage: %s "commit message" "file" ["file" ...]\n' "$(basename "$0")" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
if [ "$#" -lt 2 ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
commit_message=$1
|
||||
shift
|
||||
|
||||
if [[ "$commit_message" != *[![:space:]]* ]]; then
|
||||
printf 'Error: commit message must not be empty\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -e "$commit_message" ]; then
|
||||
printf 'Error: first argument looks like a file path ("%s"); provide the commit message first\n' "$commit_message" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
files=("$@")
|
||||
|
||||
for file in "${files[@]}"; do
|
||||
if [ ! -e "$file" ]; then
|
||||
if ! git ls-files --error-unmatch -- "$file" >/dev/null 2>&1; then
|
||||
printf 'Error: file not found: %s\n' "$file" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
git restore --staged :/
|
||||
git add --force -- "${files[@]}"
|
||||
|
||||
if git diff --staged --quiet; then
|
||||
printf 'Warning: no staged changes detected for: %s\n' "${files[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git commit -m "$commit_message" -- "${files[@]}"
|
||||
|
||||
printf 'Committed "%s" with %d files\n' "$commit_message" "${#files[@]}"
|
||||
130
scripts/docs-list.ts
Normal file
130
scripts/docs-list.ts
Normal file
@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { compact } from 'es-toolkit';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const DOCS_DIR = join(__dirname, '..', 'docs');
|
||||
|
||||
const EXCLUDED_DIRS = new Set(['archive', 'research']);
|
||||
|
||||
function walkMarkdownFiles(dir: string, base: string = dir): string[] {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (EXCLUDED_DIRS.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
files.push(...walkMarkdownFiles(fullPath, base));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
files.push(relative(base, fullPath));
|
||||
}
|
||||
}
|
||||
return files.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function extractMetadata(fullPath: string): {
|
||||
summary: string | null;
|
||||
readWhen: string[];
|
||||
error?: string;
|
||||
} {
|
||||
const content = readFileSync(fullPath, 'utf8');
|
||||
|
||||
if (!content.startsWith('---')) {
|
||||
return { summary: null, readWhen: [], error: 'missing front matter' };
|
||||
}
|
||||
|
||||
const endIndex = content.indexOf('\n---', 3);
|
||||
if (endIndex === -1) {
|
||||
return { summary: null, readWhen: [], error: 'unterminated front matter' };
|
||||
}
|
||||
|
||||
const frontMatter = content.slice(3, endIndex).trim();
|
||||
const lines = frontMatter.split('\n');
|
||||
|
||||
let summaryLine: string | null = null;
|
||||
const readWhen: string[] = [];
|
||||
let collectingField: 'read_when' | null = null;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('summary:')) {
|
||||
summaryLine = line;
|
||||
collectingField = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('read_when:')) {
|
||||
collectingField = 'read_when';
|
||||
const inline = line.slice('read_when:'.length).trim();
|
||||
if (inline.startsWith('[') && inline.endsWith(']')) {
|
||||
try {
|
||||
const parsed = JSON.parse(inline.replace(/'/g, '"')) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
readWhen.push(...compact(parsed.map((item) => String(item).trim())));
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed inline arrays
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (collectingField === 'read_when') {
|
||||
if (line.startsWith('- ')) {
|
||||
const hint = line.slice(2).trim();
|
||||
if (hint) {
|
||||
readWhen.push(hint);
|
||||
}
|
||||
} else if (line === '') {
|
||||
} else {
|
||||
collectingField = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!summaryLine) {
|
||||
return { summary: null, readWhen, error: 'summary key missing' };
|
||||
}
|
||||
|
||||
const summaryValue = summaryLine.slice('summary:'.length).trim();
|
||||
const normalized = summaryValue
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return { summary: null, readWhen, error: 'summary is empty' };
|
||||
}
|
||||
|
||||
return { summary: normalized, readWhen };
|
||||
}
|
||||
|
||||
console.log('Listing all markdown files in docs folder:');
|
||||
|
||||
const markdownFiles = walkMarkdownFiles(DOCS_DIR);
|
||||
|
||||
for (const relativePath of markdownFiles) {
|
||||
const fullPath = join(DOCS_DIR, relativePath);
|
||||
const { summary, readWhen, error } = extractMetadata(fullPath);
|
||||
if (summary) {
|
||||
console.log(`${relativePath} - ${summary}`);
|
||||
if (readWhen.length > 0) {
|
||||
console.log(` Read when: ${readWhen.join('; ')}`);
|
||||
}
|
||||
} else {
|
||||
const reason = error ? ` - [${error}]` : '';
|
||||
console.log(`${relativePath}${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nReminder: keep docs up to date as behavior changes. When your task matches any "Read when" hint above (React hooks, cache directives, database work, tests, etc.), read that doc before coding, and suggest new coverage when it is missing.'
|
||||
);
|
||||
159
scripts/git-policy.ts
Normal file
159
scripts/git-policy.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export type GitInvocation = {
|
||||
index: number;
|
||||
argv: string[];
|
||||
};
|
||||
|
||||
export type GitCommandInfo = {
|
||||
name: string;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type GitExecutionContext = {
|
||||
invocation: GitInvocation | null;
|
||||
command: GitCommandInfo | null;
|
||||
subcommand: string | null;
|
||||
workDir: string;
|
||||
};
|
||||
|
||||
export type GitPolicyEvaluation = {
|
||||
requiresCommitHelper: boolean;
|
||||
requiresExplicitConsent: boolean;
|
||||
isDestructive: boolean;
|
||||
};
|
||||
|
||||
const COMMIT_HELPER_SUBCOMMANDS = new Set(['add', 'commit']);
|
||||
const GUARDED_SUBCOMMANDS = new Set(['push', 'pull', 'merge', 'rebase', 'cherry-pick']);
|
||||
const DESTRUCTIVE_SUBCOMMANDS = new Set([
|
||||
'reset',
|
||||
'checkout',
|
||||
'clean',
|
||||
'restore',
|
||||
'switch',
|
||||
'stash',
|
||||
'branch',
|
||||
'filter-branch',
|
||||
'fast-import',
|
||||
]);
|
||||
|
||||
export function extractGitInvocation(commandArgs: string[]): GitInvocation | null {
|
||||
for (const [index, token] of commandArgs.entries()) {
|
||||
if (token === 'git' || token.endsWith('/git')) {
|
||||
return { index, argv: commandArgs.slice(index) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findGitSubcommand(commandArgs: string[]): GitCommandInfo | null {
|
||||
if (commandArgs.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const optionsWithValue = new Set(['-C', '--git-dir', '--work-tree', '-c']);
|
||||
let index = 1;
|
||||
|
||||
while (index < commandArgs.length) {
|
||||
const token = commandArgs[index];
|
||||
if (token === '--') {
|
||||
const next = commandArgs[index + 1];
|
||||
return next ? { name: next, index: index + 1 } : null;
|
||||
}
|
||||
if (!token.startsWith('-')) {
|
||||
return { name: token, index };
|
||||
}
|
||||
if (token.includes('=')) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (optionsWithValue.has(token)) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function determineGitWorkdir(baseDir: string, gitArgs: string[], command: GitCommandInfo | null): string {
|
||||
let workDir = baseDir;
|
||||
const limit = command ? command.index : gitArgs.length;
|
||||
let index = 1;
|
||||
|
||||
while (index < limit) {
|
||||
const token = gitArgs[index];
|
||||
if (token === '-C') {
|
||||
const next = gitArgs[index + 1];
|
||||
if (next) {
|
||||
workDir = resolve(workDir, next);
|
||||
}
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('-C')) {
|
||||
const pathSegment = token.slice(2);
|
||||
if (pathSegment.length > 0) {
|
||||
workDir = resolve(workDir, pathSegment);
|
||||
}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return workDir;
|
||||
}
|
||||
|
||||
export function analyzeGitExecution(commandArgs: string[], workspaceDir: string): GitExecutionContext {
|
||||
const invocation = extractGitInvocation(commandArgs);
|
||||
const command = invocation ? findGitSubcommand(invocation.argv) : null;
|
||||
const workDir = invocation ? determineGitWorkdir(workspaceDir, invocation.argv, command) : workspaceDir;
|
||||
|
||||
return {
|
||||
invocation,
|
||||
command,
|
||||
subcommand: command?.name ?? null,
|
||||
workDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function requiresCommitHelper(subcommand: string | null): boolean {
|
||||
if (!subcommand) {
|
||||
return false;
|
||||
}
|
||||
return COMMIT_HELPER_SUBCOMMANDS.has(subcommand);
|
||||
}
|
||||
|
||||
export function requiresExplicitGitConsent(subcommand: string | null): boolean {
|
||||
if (!subcommand) {
|
||||
return false;
|
||||
}
|
||||
return GUARDED_SUBCOMMANDS.has(subcommand);
|
||||
}
|
||||
|
||||
export function isDestructiveGitSubcommand(command: GitCommandInfo | null, gitArgv: string[]): boolean {
|
||||
if (!command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subcommand = command.name;
|
||||
if (DESTRUCTIVE_SUBCOMMANDS.has(subcommand)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (subcommand === 'bisect') {
|
||||
const action = gitArgv[command.index + 1] ?? '';
|
||||
return action === 'reset';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function evaluateGitPolicies(context: GitExecutionContext): GitPolicyEvaluation {
|
||||
const invocationArgv = context.invocation?.argv;
|
||||
const normalizedArgv = Array.isArray(invocationArgv) ? invocationArgv : [];
|
||||
return {
|
||||
requiresCommitHelper: requiresCommitHelper(context.subcommand),
|
||||
requiresExplicitConsent: requiresExplicitGitConsent(context.subcommand),
|
||||
isDestructive: isDestructiveGitSubcommand(context.command, normalizedArgv),
|
||||
};
|
||||
}
|
||||
1242
scripts/runner.ts
Normal file
1242
scripts/runner.ts
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user