Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
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
19 changes: 19 additions & 0 deletions .claude/agents/typescript-quality/AGENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
name: typescript-quality
description: |
Fixes lint and tests, and is only allowed to use read and write commands. Hooks run the tests
isolation: none
tools: Edit, Write, Read
model: glm-4.5-air
hooks:
SessionStart:
- hooks:
- type: command
command: $CLAUDE_PROJECT_DIR/.claude/hooks/dispatch typescript quality common
Stop:
- hooks:
- type: command
command: $CLAUDE_PROJECT_DIR/.claude/hooks/dispatch typescript quality common
---

Fix the listed lint and test errors. When you fix them, quit. Do not look for more errors.
56 changes: 56 additions & 0 deletions .claude/hooks/dispatch
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/bin/bash

set -e

INPUT=$(cat)

escape_json() {
JSON=$(echo -n "$1" | jq -Rca . | tail -c +2 | head -c -2)

echo "$JSON"
}

get_field() {
local field="$1"
local json="${2:-$INPUT}"
echo "$json" | jq -r ".$field // empty" 2>/dev/null || echo ""
}

deny() {
JSON=$(escape_json "$1")
echo '{"hookSpecificOutput":{"hookEventName":"'$(get_field hook_event_name)'","permissionDecision":"deny","permissionDecisionReason":"'$JSON'"}}'

exit 2
}

block() {
JSON=$(escape_json "$1")
echo -e '{"hookSpecificOutput":{"hookEventName":"'$(get_field hook_event_name)'","decision":"block","reason":"'$JSON'"}}' >&2

exit 2
}

additionalContext() {
JSON=$(escape_json "$1")
echo -e '{"hookSpecificOutput":{"hookEventName":"'$(get_field hook_event_name)'","additionalContext":"'$JSON'"}}'

exit 0
}
Comment on lines +19 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Shell/JSON injection: all three output functions are vulnerable.

The deny(), block(), and additionalContext() functions use escape_json() but then concatenate the result into a shell string literal. This approach is still vulnerable:

  1. If $(get_field hook_event_name) contains " or other JSON metacharacters, it will produce malformed JSON
  2. The shell string interpolation defeats the escaping done by escape_json()
  3. An attacker controlling hook payloads could inject arbitrary JSON keys

Use jq -n with --arg flags to construct JSON safely:

🛡️ Proposed fix for all three functions
 deny() {
-  JSON=$(escape_json "$1")
-  echo '{"hookSpecificOutput":{"hookEventName":"'$(get_field hook_event_name)'","permissionDecision":"deny","permissionDecisionReason":"'$JSON'"}}'
-
+  jq -n \
+    --arg event "$(get_field hook_event_name)" \
+    --arg reason "$1" \
+    '{"hookSpecificOutput":{"hookEventName":$event,"permissionDecision":"deny","permissionDecisionReason":$reason}}'
   exit 2
 }
 
 block() {
-  JSON=$(escape_json "$1")
-  echo -e '{"hookSpecificOutput":{"hookEventName":"'$(get_field hook_event_name)'","decision":"block","reason":"'$JSON'"}}' >&2
-
+  jq -n \
+    --arg event "$(get_field hook_event_name)" \
+    --arg reason "$1" \
+    '{"hookSpecificOutput":{"hookEventName":$event,"decision":"block","reason":$reason}}' >&2
   exit 2
 }
 
 additionalContext() {
-  JSON=$(escape_json "$1")
-  echo -e '{"hookSpecificOutput":{"hookEventName":"'$(get_field hook_event_name)'","additionalContext":"'$JSON'"}}'
-
+  jq -n \
+    --arg event "$(get_field hook_event_name)" \
+    --arg context "$1" \
+    '{"hookSpecificOutput":{"hookEventName":$event,"additionalContext":$context}}'
   exit 0
 }

If this fix is applied, the escape_json() function becomes unused and should be removed (or kept only if other code needs it).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/hooks/dispatch around lines 21 - 40, The deny(), block(), and
additionalContext() functions build JSON by concatenating shell-escaped strings
which can still be broken by characters in $(get_field hook_event_name) or
payloads; replace each manual JSON assembly with jq -n and --arg to safely
construct the JSON object (e.g. call get_field and capture its output into a
variable, then use jq -n --arg hook_event_name "$HOOK" --arg reason "$1"
'{hookSpecificOutput: {hookEventName:$hook_event_name,
permissionDecision:"deny", permissionDecisionReason:$reason}}' or similar for
each function, writing block’s output to stderr as currently done), keep the
same exit codes, and remove escape_json() if no other code uses it; reference
functions: deny, block, additionalContext, escape_json, get_field.


sentinel() {
SESSION_ID=$(get_field "session_id")

mkdir -p /tmp/.claude

echo "/tmp/.claude/$SESSION_ID.$1.sentinel"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export -f get_field
export -f deny
export -f block
export -f sentinel
export -f additionalContext
export -f escape_json
export INPUT

$CLAUDE_PROJECT_DIR/.claude/skills/$1/hooks/${3:-$(get_field hook_event_name)}.d/$2
47 changes: 47 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(npx *)",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/dispatch node ban-npx"
}
]
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/dispatch typescript lint-oxlint"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/dispatch typescript format"
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/dispatch typescript lint-capture"
}
]
}
]
}
}
53 changes: 53 additions & 0 deletions .claude/skills/node/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
name: node
description: |
Use when working with Node.js, npm, package.json, or npx. Alternative package managers
like Yarn and pnpm as well as monorepo tools like nx, yarn, or turborepo should also use this skill
---

# Node.js

## Invoking scripts

Prefer explicit `scripts` entries over `npx`. Write scripts to package.json:

```json
"scripts": {
"test": "vitest run",
"dev": "vitest --watch",
"build": "vite build"
}
```

Reason: Enforces consistent flags, prevents drift, works better in monorepos.

## Monorepos

Use native npm workspaces unless user explicitly chose otherwise (turborepo, nx, pnpm).

**Workspace pattern** ([examples/workspace-setup](examples/workspace-setup.md)):
- Root `workspaces: ["packages/**"]` in package.json
- Workspace scripts: `npm run build -ws`, `npm run test -ws`
- Local deps use workspace names: `@bifrost-ai/core`

**TypeScript project references** ([examples/tsconfig-references](examples/tsconfig-references.md)):
- Base config with `composite: true`
- Package configs extend base, declare `references`
- Enables cross-package type checking, faster builds

**Build** ([examples/vite-build](examples/vite-build.md)):
- Vite for fast builds
- `vite-plugin-dts` for .d.ts generation
- `external:` workspace deps in rollupOptions

**Testing** ([examples/vitest-setup](examples/vitest-setup.md)):
- Vitest config per workspace or root
- `*.spec.ts` naming, exclude from builds
- Root script runs all: `"test": "vitest run"`

## See also

- [TypeScript config](examples/tsconfig-references.md) - Project references pattern
- [Vite build](examples/vite-build.md) - Library build with deps
- [Vitest setup](examples/vitest-setup.md) - Test config for monorepos
- [Workspace setup](examples/workspace-setup.md) - Complete monorepo structure
44 changes: 44 additions & 0 deletions .claude/skills/node/examples/tsconfig-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# TypeScript Project References

Enables cross-package type checking, faster incremental builds.

Root tsconfig.base.json:

```json
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"lib": ["ESNext"],
"types": ["node"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true
},
"exclude": ["node_modules", "dist"]
}
```

Package tsconfig.json (packages/core/tsconfig.json):

```json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"references": [
{ "path": "../another-monorepo-package" }
],
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
}
```
46 changes: 46 additions & 0 deletions .claude/skills/node/examples/vite-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Vite Build for Libraries

Fast builds, TypeScript declarations, workspace deps.

vite.config.ts:

```ts
import { defineConfig } from 'vite'
import dts from 'vite-plugin-dts'
import pkg from './package.json'

export default defineConfig({
plugins: [
dts({
tsconfigPath: './tsconfig.json',
rollupTypes: true,
}),
],
build: {
lib: {
entry: './src/index.ts',
formats: ['es'],
fileName: 'index',
},
rollupOptions: {
external: [
'@my-monorepo/utils',
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
],
output: {
globals: {
'@my-monorepo/utils': 'Utils',
},
},
},
target: 'esnext',
emptyOutDir: true,
},
})
```

**Key points:**
- `external:` workspace deps + all deps
- `rollupTypes: true` bundles .d.ts files
- `formats: ['es']` for ESM-only packages
26 changes: 26 additions & 0 deletions .claude/skills/node/examples/vitest-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Vitest Testing Setup

Root vitest.config.ts:

```ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
globals: true,
environment: 'node',
},
})
```

Test file naming:
- `*.spec.ts` for unit tests
- Exclude from tsconfig `exclude` array

Usage:

```bash
npm run test # run all tests once
npm run dev # watch mode
npm run test -- -ui # vitest ui (if installed)
```
44 changes: 44 additions & 0 deletions .claude/skills/node/examples/workspace-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Npm Workspace Monorepo Setup

Root package.json:

```json
{
"name": "my-monorepo",
"private": true,
"type": "module",
"workspaces": ["packages/**"],
"scripts": {
"build": "npm run build -ws",
"test": "vitest run",
"dev": "vitest --watch"
},
"devDependencies": {
"typescript": "^6.0.3",
"vite": "^8.0.11",
"vite-plugin-dts": "^5.0.0",
"vitest": "^4.1.5"
}
}
```

Package (packages/core/package.json):

```json
{
"name": "@my-monorepo/core",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "vite build"
}
}
```
5 changes: 5 additions & 0 deletions .claude/skills/node/hooks/PreToolUse.d/ban-npx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/bash
# if: Bash(npx *)
###

deny 'npx is forbidden. use a script defined in package.json and `npm run <script-name>` instead.';
6 changes: 6 additions & 0 deletions .claude/skills/node/wanted-skills/package.json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Package.json

1. npm monorepos should ban edits that include 'workspace:*'
2. monorepo'd projects should ban devDependencies in the workspace packages. put them in the root
3. you should not have multiple versions of dependencies across a monorepo. share them
4. must run ncu and use latest versions
Loading