diff --git a/.husky/commit-msg b/.husky/commit-msg index 9e38fbf3c..a7adb1935 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -15,3 +15,14 @@ if ! echo "$msg" | grep -qE '^(feat|fix|docs|chore|refactor|test|perf|ci|build|r echo "Got: $msg" exit 1 fi + +# Strip AI co-authorship trailers from commit message +# These are injected by AI coding tools and misrepresent authorship. +# See docs/AI_ATTRIBUTION_POLICY.md for the full list. +if [ "$(uname)" = "Darwin" ]; then + sed -i '' '/^Co-authored-by:.*[Cc]laude\|[Cc]opilot\|[Cc]ursor\|[Cc]hat[Gg][Pp][Tt]\|[Oo]pen[Aa][Ii]\|[Aa]nthrop\|[Dd]evin\|[Aa]ider\|[Cc]odeium\|[Tt]abnine\|[Ww]indsurf\|[Cc]ody\|[Cc]ontinue\|[Aa]ugment\|[Ss]upermaven/d' "$1" + sed -i '' '/^Authored-by:.*AI\|^Written-by:.*AI\|^Generated-by:/d' "$1" +else + sed -i '/^Co-authored-by:.*[Cc]laude\|[Cc]opilot\|[Cc]ursor\|[Cc]hat[Gg][Pp][Tt]\|[Oo]pen[Aa][Ii]\|[Aa]nthrop\|[Dd]evin\|[Aa]ider\|[Cc]odeium\|[Tt]abnine\|[Ww]indsurf\|[Cc]ody\|[Cc]ontinue\|[Aa]ugment\|[Ss]upermaven/d' "$1" + sed -i '/^Authored-by:.*AI\|^Written-by:.*AI\|^Generated-by:/d' "$1" +fi diff --git a/.husky/pre-commit b/.husky/pre-commit index f3ea72c94..91499d2bc 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -5,6 +5,12 @@ set -e bun prettier --write "packages/opencode/src/**/*.ts" "packages/plugin/src/**/*.ts" git update-index --again +# Strip AI tool attribution from staged files +scripts/strip-ai-attribution.sh + +# SAST: check for security anti-patterns in staged files +scripts/sast-check.sh + # Typecheck bun turbo typecheck 2>&1 | tail -5 diff --git a/.husky/pre-push b/.husky/pre-push index a12f01e04..746ecf791 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,5 +1,6 @@ #!/bin/sh set -e + # Check if bun version matches package.json # keep in sync with packages/script/src/index.ts semver qualifier bun -e ' @@ -17,5 +18,37 @@ if (process.versions.bun !== expectedBunVersion) { console.warn(`Warning: Bun version ${process.versions.bun} differs from expected ${expectedBunVersion}`); } ' + +# Verify no AI attribution in commits being pushed +# Checks all commits between upstream tracking branch and HEAD +commits=$(git log @{u}..HEAD --format="%H" 2>/dev/null || echo "") +for commit in $commits; do + msg=$(git log -1 --format="%B" "$commit") + if echo "$msg" | grep -qiE "co-authored-by:.*(claude|copilot|cursor|chatgpt|openai|anthropic|devin|aider|codeium|tabnine|windsurf|cody|continue|augment|supermaven)"; then + short=$(git log -1 --format="%h %s" "$commit") + echo "" + echo "ERROR: AI co-authorship attribution found in commit:" + echo " $short" + echo "" + echo "Strip it with: git rebase -i HEAD~N (edit the commit message)" + echo "See docs/AI_ATTRIBUTION_POLICY.md for details." + exit 1 + fi +done + +# Strip AI attribution from existing PR body (if gh is available and PR exists) +if command -v gh >/dev/null 2>&1; then + branch=$(git rev-parse --abbrev-ref HEAD) + pr_number=$(gh pr view "$branch" --json number --jq '.number' 2>/dev/null || echo "") + if [ -n "$pr_number" ]; then + body=$(gh pr view "$pr_number" --json body --jq '.body' 2>/dev/null || echo "") + cleaned=$(echo "$body" | sed -E '/[Cc]o-authored-by:.*(Claude|Copilot|Cursor|ChatGPT|OpenAI|Anthropic|Devin|Aider|Codeium|Tabnine|Windsurf|Cody|Continue|Augment|Supermaven)/Id; /Created with Cursor/Id; /Generated by (Claude|Copilot|ChatGPT|Codeium|Windsurf|Devin|Aider)/Id; /AI-generated/Id') + if [ "$body" != "$cleaned" ]; then + echo "$cleaned" | gh pr edit "$pr_number" --body-file - 2>/dev/null + echo "Stripped AI attribution from PR #$pr_number body" + fi + fi +fi + bun turbo typecheck bun turbo test diff --git a/BUGS.md b/BUGS.md index 3d951da41..b1718e26f 100644 --- a/BUGS.md +++ b/BUGS.md @@ -19,6 +19,18 @@ All bugs tracked here. Do not create per-package bug files. | S4 | Server unauthenticated on non-loopback | Med | Server throws if bound to non-loopback without `OPENCODE_SERVER_PASSWORD` | | S5 | Read tool exposes .env files | Med | Sensitive file deny-list; `always: []` for sensitive files forces permission prompt | +## Open — Code Quality (5) + +Found during QA bug hunt (static analysis). Not crashes, but code quality issues. + +| # | Issue | Sev | Location | Notes | +| --- | ----- | --- | -------- | ----- | +| Q1 | 95 empty `.catch(() => {})` blocks across 29 files | Low | Various | Most intentional (file ops), ~10 mask real errors in `config.ts`, `lsp/client.ts`, `sdk.tsx` | +| Q2 | 17 TODO/FIXME/HACK comments | Low | 13 files | Track as tech debt; key ones: copilot lost type safety (#374), process.env vs Env.set (#300, #524) | +| Q3 | `console.log` in TUI production code | Low | `cli/cmd/tui/` | **FIXED** in this PR — replaced 18 calls with `Log.create()` | +| Q4 | Copilot SDK lost chunk type safety | Med | `provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts:374` | TODO says "MUST FIX" — type safety lost on Chunk due to error schema | +| Q5 | `process.env` used directly instead of `Env.set` | Low | `provider/provider.ts:300,524` | Env.set only updates shallow copy, not process.env — architectural issue | + ## Open — Bugs (0) _No open bugs._ diff --git a/WHAT_WE_DID.md b/WHAT_WE_DID.md index 5a12bb041..237737448 100644 --- a/WHAT_WE_DID.md +++ b/WHAT_WE_DID.md @@ -15,8 +15,9 @@ CAS, edit graph, context editing (6 ops), side threads, objective tracker, class - **#23:** TUI types fixed, logger types strengthened (unknown → string | Error) - **#24:** Zod v4 migration (zodToJsonSchema → z.toJSONSchema), 25 Frankencode unit tests, tracking docs cleanup - **#25:** Upstream catalogue (162 commits + ~195 PRs), security audit (2 CVEs, 5 issues), 6-phase roadmap -- **#26:** Phase 1 security fixes: S1 symlink bypass, S2 exec→spawn, S4 server auth, S5 sensitive deny-list, S3 warning (13 tests) - **#27:** Phase 2 upstream fixes: prompt parts (#17815), thinkingConfig guard (#18283), chunk timeout (#18264), error messages (#18165), event queue (#18259) - **#28:** Phase 3+4 merged: OpenTUI 0.1.88 upgrade, agent ordering stability. Most other items already applied or diverged. - **#29:** Phase 5 tests: filterEdited (8), filterEphemeral (6), ContextEdit validation (10) — 24 new tests - **#30:** Phase 6 Effect analysis: all 12 upstream Effect PRs reviewed — zero need reimplementation +- **#31:** QA bug hunt: 18 console.log→Log.create() fixes, 5 code quality issues documented (Q1-Q5) +- **#32:** Hardened git hooks: SAST checks, AI attribution stripping (pre-commit/commit-msg/pre-push), PR body cleaning diff --git a/docs/AI_ATTRIBUTION_POLICY.md b/docs/AI_ATTRIBUTION_POLICY.md new file mode 100644 index 000000000..8e5d30908 --- /dev/null +++ b/docs/AI_ATTRIBUTION_POLICY.md @@ -0,0 +1,59 @@ +# AI Attribution Stripping Policy + +Frankencode automatically strips AI tool attribution from commits and staged files to maintain clean git history and accurate authorship. + +## Why + +AI coding assistants inject co-authorship trailers, watermark comments, and tool-specific markers into code and commit messages. These: +- Misrepresent authorship (the human developer is the author) +- Leak tool usage metadata unnecessarily +- Create inconsistent attribution across commits + +## What Gets Stripped + +### Git Trailer Patterns (commit messages) + + +| Pattern | Tool | +|---------|------| + +### Code Comment Watermarks (staged files) + + +| Pattern | Tool | +|---------|------| + +## When Stripping Happens + +| Hook | Action | +|------|--------| +| `pre-commit` | Auto-strips watermarks from staged files, re-stages them | +| `commit-msg` | Auto-strips co-authorship trailers from commit message | +| `pre-push` | Verifies no AI attribution in any commit being pushed (blocks if found) | + +## SAST Checks (also in pre-commit) + +The pre-commit hook also runs lightweight SAST via `scripts/sast-check.sh`: + +| Check | CWE | Severity | +|-------|-----|----------| +| No `eval()` | CWE-95 (Code Injection) | Error (blocks) | +| No `new Function()` | CWE-95 | Error (blocks) | +| No `innerHTML` | CWE-79 (XSS) | Warning | +| No hardcoded secrets | CWE-798 | Error (blocks) | +| No `console.log` in src/ | Code quality | Warning | + +Add `// sast-ignore` comment to suppress false positives. + +## Adding New Patterns + +When a new AI tool emerges that injects attribution: +1. Add the pattern to `scripts/strip-ai-attribution.sh` +2. Add the trailer pattern to `.husky/commit-msg` +3. Add the push check pattern to `.husky/pre-push` +4. Update this document + +## See Also + +- [AGENTS.md](../AGENTS.md) — development guidelines +- [docs/SECURITY_AUDIT.md](SECURITY_AUDIT.md) — security vulnerabilities diff --git a/docs/README.md b/docs/README.md index 0187b44dd..9b18d522d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ | [AGENT_CLIENT_PROTOCOL.md](AGENT_CLIENT_PROTOCOL.md) | ACP v1 protocol support for IDE integration | | [API_PROVIDERS.md](API_PROVIDERS.md) | 21+ LLM providers, models.dev API, transform pipeline | | [SECURITY_AUDIT.md](SECURITY_AUDIT.md) | CVEs, upstream security issues, Frankencode-specific vulnerabilities | +| [AI_ATTRIBUTION_POLICY.md](AI_ATTRIBUTION_POLICY.md) | AI tool attribution stripping policy, SAST checks | ## Architecture at a Glance diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 66c067255..b1375ce10 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -42,6 +42,9 @@ import { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider } from "./context/tui-config" import { TuiConfig } from "@/config/tui" +import { Log } from "@/util/log" + +const log = Log.create({ service: "tui" }) async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -191,7 +194,7 @@ export function tui(input: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], onCopySelection: (text) => { Clipboard.copy(text).catch((error) => { - console.error(`Failed to copy console selection to clipboard: ${error}`) + log.error(`Failed to copy console selection to clipboard: ${error}`) }) }, }, @@ -258,7 +261,7 @@ function App() { const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true)) createEffect(() => { - console.log(JSON.stringify(route.data)) + log.debug("route changed", { data: route.data }) }) // Update terminal window title based on current route and session diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index 9cfa30d4d..c3513e08f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -7,6 +7,9 @@ import { useTheme } from "../context/theme" import { Keybind } from "@/util/keybind" import { TextAttributes } from "@opentui/core" import { useSDK } from "@tui/context/sdk" +import { Log } from "@/util/log" + +const log = Log.create({ service: "tui" }) function Status(props: { enabled: boolean; loading: boolean }) { const { theme } = useTheme() @@ -61,10 +64,10 @@ export function DialogMcp() { if (status.data) { sync.set("mcp", status.data) } else { - console.error("Failed to refresh MCP status: no data returned") + log.error("Failed to refresh MCP status: no data returned") } } catch (error) { - console.error("Failed to toggle MCP:", error) + log.error("Failed to toggle MCP", { error: error as Error }) } finally { setLoading(null) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx index b11ad6a73..706157e87 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx @@ -9,6 +9,9 @@ import { useToast } from "../ui/toast" import { useKeybind } from "../context/keybind" import { DialogSessionList } from "./workspace/dialog-session-list" import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { Log } from "@/util/log" + +const log = Log.create({ service: "tui" }) async function openWorkspace(input: { dialog: ReturnType @@ -112,10 +115,10 @@ function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promi setCreating(type) const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => { - console.log(err) + log.error("workspace create failed", { error: err as Error }) return undefined }) - console.log(JSON.stringify(result, null, 2)) + log.debug("workspace create result", { data: result ?? "undefined" }) const workspace = result?.data if (!workspace) { setCreating(undefined) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index c13b43651..60a6eba53 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -27,6 +27,7 @@ import { TuiEvent } from "../../event" import { iife } from "@/util/iife" import { Locale } from "@/util/locale" import { formatDuration } from "@/util/format" +import { Log } from "@/util/log" import { createColors, createFrames } from "../../ui/spinner.ts" import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" @@ -36,6 +37,8 @@ import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +const log = Log.create({ service: "tui" }) + export type PromptProps = { sessionID?: string workspaceID?: string @@ -549,7 +552,7 @@ export function Prompt(props: PromptProps) { }) if (res.error) { - console.log("Creating a session failed:", res.error) + log.error("Creating a session failed", { error: res.error }) toast.show({ message: "Creating a session failed. Open console for more details.", diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index e96cd2c3a..6bd04d825 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -1,7 +1,10 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" +import { Log } from "@/util/log" import type { PromptInfo } from "../component/prompt/history" +const log = Log.create({ service: "tui" }) + export type HomeRoute = { type: "home" initialPrompt?: PromptInfo @@ -32,7 +35,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({ return store }, navigate(route: Route) { - console.log("navigate", route) + log.debug("navigate", { route }) setStore(route) }, } diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index a513e5a03..83fe24b10 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -30,6 +30,8 @@ import { Log } from "@/util/log" import type { Path } from "@opencode-ai/sdk" import type { Workspace } from "@opencode-ai/sdk/v2" +const log = Log.create({ service: "tui" }) + export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", init: () => { @@ -356,7 +358,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const args = useArgs() async function bootstrap() { - console.log("bootstrapping") + log.debug("bootstrapping") const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const sessionListPromise = sdk.client.session .list({ start: start }) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 2320c08cc..4975677b5 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -2,6 +2,7 @@ import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core" import path from "path" import { createEffect, createMemo, onMount } from "solid-js" import { createSimpleContext } from "./helper" +import { Log } from "@/util/log" import { Glob } from "../../../../util/glob" import aura from "./theme/aura.json" with { type: "json" } import ayu from "./theme/ayu.json" with { type: "json" } @@ -43,6 +44,8 @@ import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { useTuiConfig } from "./tui-config" +const log = Log.create({ service: "tui" }) + type ThemeColors = { primary: RGBA secondary: RGBA @@ -317,13 +320,13 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ onMount(init) function resolveSystemTheme() { - console.log("resolveSystemTheme") + log.debug("resolveSystemTheme") renderer .getPalette({ size: 16, }) .then((colors) => { - console.log(colors.palette) + log.debug("system palette", { palette: colors.palette }) if (!colors.palette[0]) { if (store.active === "system") { setStore( diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 40414b522..b315d4602 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -15,6 +15,7 @@ import { import { Dynamic } from "solid-js/web" import path from "path" import { useRoute, useRouteData } from "@tui/context/route" +import { Log } from "@/util/log" import { useSync } from "@tui/context/sync" import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" @@ -83,6 +84,8 @@ import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" +const log = Log.create({ service: "tui" }) + addDefaultParsers(parsers.parsers) class CustomSpeedScroll implements ScrollAcceleration { @@ -196,7 +199,7 @@ export function Session() { if (scroll) scroll.scrollBy(100_000) }) .catch((e) => { - console.error(e) + log.error("session sync failed", { error: e as Error }) toast.show({ message: `Session not found: ${route.sessionID}`, variant: "error", diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 85e13d313..c846c8698 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -7,6 +7,9 @@ import fs from "fs/promises" import { Filesystem } from "../../../../util/filesystem" import { Process } from "../../../../util/process" import { which } from "../../../../util/which" +import { Log } from "@/util/log" + +const log = Log.create({ service: "tui" }) /** * Writes text to clipboard via OSC 52 escape sequence. @@ -95,7 +98,7 @@ export namespace Clipboard { const os = platform() if (os === "darwin" && which("osascript")) { - console.log("clipboard: using osascript") + log.debug("clipboard: using osascript") return async (text: string) => { const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true }) @@ -104,7 +107,7 @@ export namespace Clipboard { if (os === "linux") { if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) { - console.log("clipboard: using wl-copy") + log.debug("clipboard: using wl-copy") return async (text: string) => { const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) if (!proc.stdin) return @@ -114,7 +117,7 @@ export namespace Clipboard { } } if (which("xclip")) { - console.log("clipboard: using xclip") + log.debug("clipboard: using xclip") return async (text: string) => { const proc = Process.spawn(["xclip", "-selection", "clipboard"], { stdin: "pipe", @@ -128,7 +131,7 @@ export namespace Clipboard { } } if (which("xsel")) { - console.log("clipboard: using xsel") + log.debug("clipboard: using xsel") return async (text: string) => { const proc = Process.spawn(["xsel", "--clipboard", "--input"], { stdin: "pipe", @@ -144,7 +147,7 @@ export namespace Clipboard { } if (os === "win32") { - console.log("clipboard: using powershell") + log.debug("clipboard: using powershell") return async (text: string) => { // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.) const proc = Process.spawn( @@ -169,7 +172,7 @@ export namespace Clipboard { } } - console.log("clipboard: no native support") + log.debug("clipboard: no native support") return async (text: string) => { await clipboardy.write(text).catch(() => {}) } diff --git a/scripts/sast-check.sh b/scripts/sast-check.sh new file mode 100755 index 000000000..973d851d9 --- /dev/null +++ b/scripts/sast-check.sh @@ -0,0 +1,111 @@ +#!/bin/sh +# Lightweight SAST (Static Application Security Testing) for staged files. +# Runs as part of pre-commit to catch security anti-patterns before they land. +# +# Checks: +# 1. No eval() or Function() constructor (code injection) +# 2. No innerHTML (XSS) +# 3. No exec() with string interpolation (command injection) +# 4. No hardcoded secrets +# 5. No console.log in src/ (use Log.create) +# +# This is NOT a replacement for a full SAST tool — it catches the most +# common anti-patterns. For comprehensive scanning, use tools like +# Semgrep, CodeQL, or Snyk. + +set -e + +STAGED=$(git diff --cached --name-only --diff-filter=ACM -- '*.ts' '*.tsx' 2>/dev/null || true) + +if [ -z "$STAGED" ]; then + exit 0 +fi + +ERRORS=0 +WARNINGS=0 + +fail() { + echo " SAST ERROR: $1" + ERRORS=$((ERRORS + 1)) +} + +warn() { + echo " SAST WARN: $1" + WARNINGS=$((WARNINGS + 1)) +} + +# Filter to only src/ files (not test files) +SRC_FILES="" +for f in $STAGED; do + case "$f" in + */src/*.ts|*/src/*.tsx) SRC_FILES="$SRC_FILES $f" ;; + esac +done + +if [ -z "$SRC_FILES" ]; then + exit 0 +fi + +# 1. No eval() — code injection risk (CWE-95) +for f in $SRC_FILES; do + matches=$(grep -n '\beval(' "$f" 2>/dev/null | grep -v '// sast-ignore' || true) + if [ -n "$matches" ]; then + fail "eval() detected in $f — use a safer alternative" + echo "$matches" + fi +done + +# 2. No new Function() — code injection risk (CWE-95) +for f in $SRC_FILES; do + matches=$(grep -n 'new Function(' "$f" 2>/dev/null | grep -v '// sast-ignore' || true) + if [ -n "$matches" ]; then + fail "new Function() detected in $f — use a safer alternative" + echo "$matches" + fi +done + +# 3. No innerHTML — XSS risk (CWE-79) +for f in $SRC_FILES; do + matches=$(grep -n 'innerHTML' "$f" 2>/dev/null | grep -v '// sast-ignore' || true) + if [ -n "$matches" ]; then + warn "innerHTML detected in $f — prefer textContent or a sanitizer" + echo "$matches" + fi +done + +# 4. No hardcoded secrets (CWE-798) +for f in $SRC_FILES; do + matches=$(grep -nE "(password|secret|token|api[_-]?key)\s*[:=]\s*['\"][A-Za-z0-9_/+=]{8,}['\"]" "$f" 2>/dev/null | grep -v '// sast-ignore' | grep -v 'test' | grep -v 'example' | grep -v 'placeholder' || true) + if [ -n "$matches" ]; then + fail "Possible hardcoded secret in $f" + echo "$matches" + fi +done + +# 5. No console.log/warn/error in src/ (use Log.create instead) +for f in $SRC_FILES; do + # Skip test fixtures and config files + case "$f" in + */test/*|*/fixture/*|*bunfig*) continue ;; + esac + matches=$(grep -n 'console\.\(log\|warn\|error\)' "$f" 2>/dev/null | grep -v '// sast-ignore' || true) + if [ -n "$matches" ]; then + warn "console.* in $f — use Log.create() for structured logging" + echo "$matches" + fi +done + +# Summary +if [ "$ERRORS" -gt 0 ]; then + echo "" + echo "SAST: $ERRORS error(s), $WARNINGS warning(s) — commit blocked" + echo "Add '// sast-ignore' comment to suppress false positives" + exit 1 +fi + +if [ "$WARNINGS" -gt 0 ]; then + echo "" + echo "SAST: $WARNINGS warning(s) — review recommended" +fi + +exit 0 diff --git a/scripts/strip-ai-attribution.sh b/scripts/strip-ai-attribution.sh new file mode 100755 index 000000000..7bf5821b7 --- /dev/null +++ b/scripts/strip-ai-attribution.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# Strip AI tool attribution from staged files. +# Runs as part of pre-commit to auto-clean, and pre-push to verify. +# +# Why: AI coding assistants inject co-authorship trailers, watermark comments, +# and tool-specific markers. These are noise that clutters git history and +# misrepresents authorship. +# +# Maintained list of known patterns. Add new ones as tools emerge. +# See docs/AI_ATTRIBUTION_POLICY.md for full documentation. + +set -e + +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.md' 2>/dev/null || true) + +if [ -z "$STAGED_FILES" ]; then + exit 0 +fi + +CHANGED=0 + +# --- Code comment watermarks --- +# These appear as comments in source code or text in markdown. +COMMENT_PATTERNS=' +Created with Cursor +Generated by Copilot +Generated by Claude +Generated by ChatGPT +Generated by Codeium +Generated by Windsurf +Generated by Devin +Generated by Aider +Generated by Tabnine +Generated by Cody +Generated by Continue +Generated by Augment +Generated by Supermaven +cursor-ai +' + +for pattern in $COMMENT_PATTERNS; do + # Skip empty lines + [ -z "$pattern" ] && continue + for file in $STAGED_FILES; do + if grep -q "$pattern" "$file" 2>/dev/null; then + # Use portable sed (macOS + Linux) + if [ "$(uname)" = "Darwin" ]; then + sed -i '' "/$pattern/d" "$file" + else + sed -i "/$pattern/d" "$file" + fi + git add "$file" + CHANGED=$((CHANGED + 1)) + echo " stripped: '$pattern' from $file" + fi + done +done + +if [ "$CHANGED" -gt 0 ]; then + echo "AI attribution: stripped $CHANGED pattern(s) from staged files" +fi