diff --git a/.github/workflows/agent-guard-hook-ci.yml b/.github/workflows/agent-guard-hook-ci.yml new file mode 100644 index 0000000..0dceb2e --- /dev/null +++ b/.github/workflows/agent-guard-hook-ci.yml @@ -0,0 +1,285 @@ +# agent-guard-hook CI Workflow +# +# All builds — dev AND release — upload to the same internal generic repo +# (dev-master-generic-local/agent-guard-hook//). The difference is +# what happens *after* the upload: +# +# - pull_request → validation only. Runs pre-build + build-and-upload's +# local steps (sed-inject + tar). NO Artifactory upload, NO post-build. +# - push (main) → "dev" upload to dev-master-generic-local. +# - workflow_dispatch with build-type=release → "release" upload to the same +# dev-master-generic-local, plus post-build (Release Bundle v2 + promotion), +# distribution (mirror to releases.jfrog.io/coding-agents-generic/), +# and promote-latest. Run manually after the dev build has been verified. + + +name: agent-guard-hook CI + +on: + push: + branches: [main] + paths: + - "agent-guard-hook/**" + - ".github/workflows/agent-guard-hook-ci.yml" + pull_request: + branches: [main] + paths: + - "agent-guard-hook/**" + - ".github/workflows/agent-guard-hook-ci.yml" + workflow_dispatch: + inputs: + build-type: + description: 'dev (default) = internal Artifactory only. release = full publish to releases.jfrog.io.' + required: false + type: choice + options: + - dev + - release + default: 'dev' + +concurrency: + group: agent-guard-hook-${{ github.ref }} + cancel-in-progress: false + +permissions: + id-token: write + contents: write # needed for RC tag + final tag creation on release builds + +env: + JF_URL: ${{ vars.JF_URL }} + JF_OIDC_PROVIDER: ${{ vars.JF_OIDC_PROVIDER }} + JF_OIDC_AUDIENCE: ${{ vars.JF_OIDC_AUDIENCE }} + JF_PROJECT: jfml + +jobs: + # Phase 1 — Pre-Build: generate metadata, decide build_type, resolve repos, + # validate dev repos + auto-create the onPushToGH webhook, create RC tag. + + # metadata example for pre-build step + # { + # "service_name": "agent-guard-hook", + # "version": "0.1.1", // computed from existing git tags + # "build_type": "release", + # "build_number": "20260527…", + # "rc_tag": "agent-guard-hook/v0.1.1-rc1", + # "promotion_stage": "release", // empty for dev builds + # "repositories": { + # "generic": { + # "deploy": "dev-master-generic-local" + # } + # } + # } + pre-build: + name: Pre-Build + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + metadata: ${{ steps.pre-build.outputs.metadata }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.head_ref || github.ref }} + + - uses: JFROG/next-gen-ci-pre-build@v7 + id: pre-build + with: + project: jfml + service-name: agent-guard-hook + short-service-name: aghook # used only on preRelease/aghook-* branches + # 'release' only when explicitly requested via workflow_dispatch. + # Everything else (PR validation, push to main) is a dev build. + build-type: ${{ inputs.build-type == 'release' && 'release' || 'dev' }} + # No RC tags on PRs — they're throwaway validation runs. + create-release-candidate-tag: ${{ github.event_name == 'pull_request' && 'false' || 'true' }} + # GitHub App credentials so it can push tags + cross-repo-token-app-id: ${{ vars.CROSS_REPO_TOKEN_APP_ID }} + cross-repo-token-private-key: ${{ secrets.CROSS_REPO_TOKEN_PRIVATE_KEY }} + generic: 'true' # Single-file ".mjs" packaged as a generic .tgz. + + # sed the metadata.version into line 2 of the.mjs, tar, upload to the deploy repo, publish build-info. + build-and-upload: + name: Build & Upload + needs: pre-build + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + version: ${{ steps.metadata.outputs.version }} + env: + VERSION: ${{ fromJSON(needs.pre-build.outputs.metadata).version }} + BUILD_NAME: ${{ fromJSON(needs.pre-build.outputs.metadata).service_name }}-${{ fromJSON(needs.pre-build.outputs.metadata).build_type }} + BUILD_NUMBER: ${{ fromJSON(needs.pre-build.outputs.metadata).build_number }} + TARGET_REPO: ${{ fromJSON(needs.pre-build.outputs.metadata).repositories.generic.deploy }} + steps: + - name: Checkout (pinned to RC tag when available) + uses: actions/checkout@v4 + with: + ref: ${{ fromJSON(needs.pre-build.outputs.metadata).rc_tag || github.head_ref || github.ref }} + + - name: Expose version as a job output + id: metadata + run: | + set -e + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "Building agent-guard-hook ${VERSION}" + + # Inject the metadata-derived version into line 2 of the .mjs. + - name: Inject version into agent-guard-hook.mjs + run: | + set -e + FILE=agent-guard-hook/agent-guard-hook.mjs + # Replace the version marker on line 2. The pattern accepts any + # existing value so re-runs are idempotent. + sed -i -E "2s|^// agent-guard-hook-version: .*$|// agent-guard-hook-version: ${VERSION}|" "${FILE}" + echo "Line 2 after injection:" + sed -n '2p' "${FILE}" + # Guard rail: fail loudly if the marker line didn't end up containing the version. + grep -q "^// agent-guard-hook-version: ${VERSION}$" <(sed -n '2p' "${FILE}") \ + || { echo "version injection failed" >&2; exit 1; } + + # Needed by upload + build-publish steps below + - name: Install build tools + id: install-tools + uses: JFROG/install-tools@v1 + with: + install-ngci: 'true' + + # Two artifacts: the versioned archive + a plain-text LATEST file with the version string + - name: Build install package + run: | + set -e + cd agent-guard-hook + mkdir -p dist + TGZ="agent-guard-hook-${VERSION}.tgz" + tar -czf "dist/${TGZ}" agent-guard-hook.mjs + echo "${VERSION}" > dist/LATEST + ls -la dist/ + + # Goes to ${TARGET_REPO}/agent-guard-hook/…. Uses OIDC token from install-tools outputs. + # Gated on event_name so PR runs never write to Artifactory. + - name: Upload artifacts to Artifactory + if: github.event_name != 'pull_request' + env: + JF_ACCESS_TOKEN: ${{ steps.install-tools.outputs.oidc-token }} + run: | + set -e + BASE="${TARGET_REPO}/agent-guard-hook" + + echo "==> Versioned archive -> ${BASE}/${VERSION}/" + jfrog rt upload --fail-no-op --quiet --flat=true \ + --build-name="${BUILD_NAME}" --build-number="${BUILD_NUMBER}" \ + --project="${JF_PROJECT}" \ + "agent-guard-hook/dist/agent-guard-hook-${VERSION}.tgz" "${BASE}/${VERSION}/" + + echo "==> Top-level artifacts -> ${BASE}/" + for f in install.mjs com.jfrog.agent-guard-hook.mobileconfig; do + jfrog rt upload --fail-no-op --quiet --flat=true \ + --build-name="${BUILD_NAME}" --build-number="${BUILD_NUMBER}" \ + --project="${JF_PROJECT}" \ + "agent-guard-hook/${f}" "${BASE}/${f}" + done + jfrog rt upload --fail-no-op --quiet --flat=true \ + --build-name="${BUILD_NAME}" --build-number="${BUILD_NUMBER}" \ + --project="${JF_PROJECT}" \ + "agent-guard-hook/dist/LATEST" "${BASE}/LATEST" + + # Tells Artifactory "these uploads belong to build ${BUILD_NAME}/${BUILD_NUMBER}". This is what later phases discover by name. + - name: Publish build info + if: github.event_name != 'pull_request' + env: + JF_ACCESS_TOKEN: ${{ steps.install-tools.outputs.oidc-token }} + run: | + set -e + jfrog rt build-publish "${BUILD_NAME}" "${BUILD_NUMBER}" --project="${JF_PROJECT}" + + # Finds the build-info we just published (by name + timestamp from metadata). + # Aggregates it into a Release Bundle — JFrog's signed, immutable collection of artifacts. + # If metadata.promotion_stage is non-empty (release builds), promotes the bundle to that environment. + # On promotion success, pushes the final git tag (agent-guard-hook/v0.1.1) and deletes the RC tag. + # Outputs the bundle name + version + final tag, exposed as job outputs. + #For PR / dev builds: promotion_stage is empty, so step 3+ skip. The job still succeeds. + post-build: + name: Post-Build + needs: [pre-build, build-and-upload] + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + promotion-successful: ${{ steps.post-build.outputs.promotion-successful }} + bundle-name: ${{ steps.post-build.outputs.bundle-name }} + bundle-version: ${{ steps.post-build.outputs.bundle-version }} + final-tag: ${{ steps.post-build.outputs.final-tag }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: JFROG/next-gen-ci-post-build@v4 + id: post-build + with: + metadata: ${{ needs.pre-build.outputs.metadata }} + # cross-repo-token used for final tag push + RC tag cleanup. + cross-repo-token-app-id: ${{ vars.CROSS_REPO_TOKEN_APP_ID }} + cross-repo-token-private-key: ${{ secrets.CROSS_REPO_TOKEN_PRIVATE_KEY }} + + # Distribution: mirror release builds to releases.jfrog.io. Only fires on release builds. + # What this does: takes the Release Bundle post-build created, and mirrors it from the internal Artifactory to + # releases.jfrog.io — the customer-facing edge. The .jfrog-distribution.yml file lists which files inside the + # bundle to include in the mirror. After this step succeeds, the artifacts are live on + # releases.jfrog.io/artifactory/coding-agents-generic/agent-guard-hook//…. + distribution: + name: Distribution + needs: [pre-build, build-and-upload, post-build] + if: ${{ fromJSON(needs.pre-build.outputs.metadata).build_type == 'release' && needs.post-build.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + + - name: Distribute to release edges + uses: JFROG/next-gen-ci-distribution@v2 + with: + metadata: ${{ needs.pre-build.outputs.metadata }} + service: ${{ fromJSON(needs.pre-build.outputs.metadata).service_name }} + version: ${{ needs.build-and-upload.outputs.version }} + project: jfml + distribution-type: onprem + distribution-manifest: agent-guard-hook/.jfrog-distribution.yml + skip-scan: 'true' + skip-clamav: 'true' + + # Copies every file under the new version directory into the latest/ directory. + promote-latest: + name: Promote to latest on releases.jfrog.io + needs: [pre-build, build-and-upload, post-build, distribution] + if: ${{ fromJSON(needs.pre-build.outputs.metadata).build_type == 'release' && needs.distribution.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + VERSION: ${{ needs.build-and-upload.outputs.version }} + RELEASES_URL: https://releases.jfrog.io/artifactory/ + steps: + - name: Install build tools + id: install-tools + uses: JFROG/install-tools@v1 + with: + install-ngci: 'true' + + - name: Copy versioned archive to latest/ on releases.jfrog.io + env: + JF_ACCESS_TOKEN: ${{ steps.install-tools.outputs.oidc-token }} + run: | + set -e + echo "Copying ${VERSION} to latest/ on releases.jfrog.io..." + jf rt cp --fail-no-op \ + "coding-agents-generic/agent-guard-hook/${VERSION}/(*)" \ + "coding-agents-generic/agent-guard-hook/latest/{1}" \ + --url="${RELEASES_URL}" \ + --access-token="${JF_ACCESS_TOKEN}" + echo "Done." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..546d06b --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# JetBrains IDE +.idea/ + +# Build / release output +mcp-gate/dist/ +dist/ + +# OS noise +.DS_Store +Thumbs.db diff --git a/agent-guard-hook/.jfrog-distribution.yml b/agent-guard-hook/.jfrog-distribution.yml new file mode 100644 index 0000000..49b421f --- /dev/null +++ b/agent-guard-hook/.jfrog-distribution.yml @@ -0,0 +1,25 @@ +# Lists the artifacts bundled into a signed release bundle and mirrored to +# releases.jfrog.io. Consumed by the `distribution` job in +# .github/workflows/agent-guard-hook-ci.yml. +# is interpolated by next-gen-ci-distribution at runtime. + +artifacts: + # Versioned archive. + - type: generic + path: agent-guard-hook//agent-guard-hook-.tgz + target: + repository: coding-agents-generic + + # Top-level files + - type: generic + path: agent-guard-hook/install.mjs + target: + repository: coding-agents-generic + - type: generic + path: agent-guard-hook/com.jfrog.agent-guard-hook.mobileconfig + target: + repository: coding-agents-generic + - type: generic + path: agent-guard-hook/LATEST + target: + repository: coding-agents-generic diff --git a/agent-guard-hook/README.md b/agent-guard-hook/README.md new file mode 100644 index 0000000..29fa0a2 --- /dev/null +++ b/agent-guard-hook/README.md @@ -0,0 +1,142 @@ +# JFrog Agent Guard Hook (VS Code) + +A VS Code PreToolUse hook that blocks MCP tool calls unless the server is +launched through JFrog's Agent Guard gateway. User-space install, single +file, no sudo, MDM-deployable. + +> **Status**: pre-release. The package is shipped via the JFrog Agent Guard +> distribution channel (`coding-agents-generic`) — same family as +> `@jfrog/agent-guard`. + +--- + +## Install + +### One-liner (what IT runs) + +```bash +# macOS / Linux +curl -fsSL https://releases.jfrog.io/artifactory/coding-agents-generic/agent-guard-hook/install.mjs | node + +# Windows (PowerShell) +iwr -useb https://releases.jfrog.io/artifactory/coding-agents-generic/agent-guard-hook/install.mjs | node +``` + +--- + +## Idempotent updates + +The hook script carries its version on line 2: + +```js +#!/usr/bin/env node +// agent-guard-hook-version: 0.1.0 +``` + +When MDM re-runs `install.mjs` periodically, the script reads that line on +the locally installed file, compares it to the staged archive, and skips +the file copy if they match. The `--register` step always runs so the +registration in `settings.json` is re-asserted on every tick. + +--- + +## Hook policy + +A tool call `mcp__` is **allowed** only if the matching server +in `mcp.json` has all of: + +- `"command": "npx"` +- `"args"` contains `"--yes"` AND `"@jfrog/agent-guard"` +- `"args"` contains `"--registry "` where the value parses as an + `http://` or `https://` URL. + on-prem Artifactory remotes both pass. + +Anything else passed through `args` after `@jfrog/agent-guard` (and the `env` +block) is the guard's concern, not the hook's. + +Anything else (different command, missing flags, no matching server) is +**denied** with exit code 2 and a one-line stderr message; VS Code surfaces +this in the chat UI. + +Example `mcp.json` entry that passes: + +```jsonc +{ + "servers": { + "chrome-devtools-mcp": { + "type": "stdio", + "command": "npx", + "args": [ + "--yes", + "--registry", "https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/", + "@jfrog/agent-guard" + ], + "env": { + "_JF_MCP_LOADER_ARGS": "project=nadav2&mcp=chrome-devtools-mcp" + } + } + } +} +``` + +--- + +## Audit log format + +`~/.vscode/hooks/agent-guard-hook.log` is append-only, one JSON object per line. + +Allow / deny: + +```jsonc +{"ts":"2026-05-27T08:14:00Z","product":"agent-guard-hook","event_type":"decision","tool_use_id":"abc-1","tool_name":"mcp_chrome-devtoo_new_page","server":"chrome-devtools-mcp","decision":"allow","reason":"npx + @jfrog/agent-guard + --registry "} +{"ts":"2026-05-27T08:14:05Z","product":"agent-guard-hook","event_type":"decision","tool_use_id":"abc-2","tool_name":"mcp_postgres_query","server":"postgres","decision":"deny","reason":"server 'postgres' does not match JFrog gateway shape (command 'docker' must be 'npx')"} +``` + +Tail with `tail -f ~/.vscode/hooks/agent-guard-hook.log`. + +--- + +## CI pipeline + +One workflow: `.github/workflows/agent-guard-hook-ci.yml`. + +| Trigger | What it does | +| --- | --- | +| pull_request → `main` | Validation only. Runs `pre-build` + `build-and-upload`'s local steps (sed-inject + tar) so a broken build fails the PR check. **No Artifactory write.** | +| push to `main` (after merge) | Dev build — versioned archive uploaded to the internal dev Artifactory repo for soak testing. **Not distributed to `releases.jfrog.io`.** | +| workflow_dispatch with `build-type: release` | Full release — uploaded to the release repo, release bundle promoted, mirrored to `releases.jfrog.io`, copied into `latest/`. Run this manually when the dev build has been verified. | +| Feature-branch pushes (no PR) | Nothing — the workflow is gated on `main` pushes and pull_requests only. | + +Jobs run in order: `pre-build` → `build-and-upload` → `post-build` → +`distribution` (release only) → `promote-latest` (release only). +On PR runs everything after `build-and-upload`'s local steps is skipped. + +### Cutting a release + +1. Merge the change to `main`. CI fires a dev build automatically; the archive lands in the internal dev Artifactory repo (`dev-main-generic-local`) with a version like `0.1.1-devf-…`. +2. Soak-test the dev archive however internal QA verifies (the dev `install.mjs` and `.tgz` are reachable from the internal dev Artifactory repo). +3. Go to **GitHub Actions → "agent-guard-hook CI" → Run workflow** and pick `build-type: release`. The version is computed by `pre-build` from the existing git tags (e.g. previous tag `agent-guard-hook/v0.1.0` → next is `v0.1.1`) and `sed`-injected into line 2 of `agent-guard-hook.mjs` during the build. +4. Verify the run; the `promote-latest` job is the last step. +5. `install.mjs` now resolves the new version through the `LATEST` file on `releases.jfrog.io`. + +### Local engineer release (before CI is wired up) + +```bash +cd agent-guard-hook +./poc/release.sh --dry-run # preview +./poc/release.sh # for real (needs `jf` logged in) +``` + +--- + +## File layout in this repo + +``` +agent-guard-hook/ +├── agent-guard-hook.mjs the hook (this is what ships to laptops) +├── install.mjs cross-platform installer +├── com.jfrog.agent-guard-hook.mobileconfig MDM payload: locks ChatHooks=true in VS Code +├── .jfrog-distribution.yml artifacts list for the distribution step +├── poc/release.sh engineer-local release fallback +└── README.md this file +``` diff --git a/agent-guard-hook/agent-guard-hook.mjs b/agent-guard-hook/agent-guard-hook.mjs new file mode 100644 index 0000000..02cdb8b --- /dev/null +++ b/agent-guard-hook/agent-guard-hook.mjs @@ -0,0 +1,345 @@ +#!/usr/bin/env node +// agent-guard-hook-version: 0.0.0-dev +// JFrog Ltd. — VS Code PreToolUse hook for the JFrog Agent Guard gateway. +// +// Line 2 above is a placeholder in the committed source. CI overwrites it +// with the metadata-derived version (e.g. "0.1.0" or "0.0.0-devf-1234.…") +// +// Modes: +// hook mode: allow / deny one tool call. +// --register this hook in VS Code settings.json. +// --version print the version marker on line 2. + +import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { dirname, join, resolve as resolvePath } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Constants +const PRODUCT_NAME = "agent-guard-hook"; +const FORCE_DISABLE_ENV = "_JF_AGENT_GUARD_HOOK_FORCE_DISABLE"; + +// Policy — the launch command we require. Anything else is DENIED. +// Example mcp.json entry that PASSES this policy: +// "chrome-devtools-mcp": { +// "command": "npx", +// "args": [ +// "--yes", +// "--registry", "https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/", +// "@jfrog/agent-guard" +// ] +// } +const POLICY = { + command: "npx", + required_args: ["--yes", "@jfrog/agent-guard"], + registry_arg: "--registry", +}; + +// Paths +const HOME = homedir(); +const HOOK_SCRIPT_PATH = fileURLToPath(import.meta.url); +const HOOK_DIR = join(HOME, ".vscode", "hooks"); +const HOOK_CONFIG_PATH = join(HOOK_DIR, "agent-guard-hook.json"); +const HOOK_CONFIG_TILDE = "~/.vscode/hooks/agent-guard-hook.json"; +const AUDIT_LOG_PATH = join(HOOK_DIR, "agent-guard-hook.log"); + +// VS Code's user-level config folder (settings.json + mcp.json live here). +const VSCODE_USER_DIR = (() => { + if (platform() === "darwin") return join(HOME, "Library/Application Support/Code/User"); // macOS + if (platform() === "win32") return join(process.env.APPDATA ?? join(HOME, "AppData/Roaming"), "Code/User"); // Windows + return join(HOME, ".config/Code/User"); // Linux +})(); +const VSCODE_SETTINGS_PATH = join(VSCODE_USER_DIR, "settings.json"); + +// Tool-name prefix VS Code uses for MCP tools, e.g. "mcp_chrome-devtoo_new_page". +const MCP_TOOL_PREFIX = "mcp_"; + + +// JSONC helpers — VS Code's settings.json + mcp.json allow comments and trailing commas that plain JSON.parse rejects. +const stripJsonc = (s) => + s + .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, comment) => (comment ? "" : m)) + .replace(/,(\s*[}\]])/g, "$1"); + +const parseJsonc = (s) => JSON.parse(stripJsonc(s)); + +const readJsoncFile = (path) => { + try { return parseJsonc(readFileSync(path, "utf8")); } catch { return null; } +}; + +const atomicWrite = (path, text) => { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + try { + writeFileSync(tmp, text, "utf8"); + renameSync(tmp, path); + } catch (err) { + // If rename failed (permissions, antivirus lock, etc.) the staging file + // would otherwise sit around forever. Best-effort cleanup, then rethrow. + try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* swallow */ } + throw err; + } +}; + + +// Audit logger — append one JSON line. +const audit = (entry) => { + try { + mkdirSync(dirname(AUDIT_LOG_PATH), { recursive: true }); + appendFileSync( + AUDIT_LOG_PATH, + JSON.stringify({ ts: new Date().toISOString(), product: PRODUCT_NAME, ...entry }) + "\n", + ); + } catch { /* best-effort */ } +}; + +const readVersion = () => { + const line = readFileSync(HOOK_SCRIPT_PATH, "utf8").split("\n", 3)[1] ?? ""; + return line.replace(/^\/\/\s*agent-guard-hook-version:\s*/, "").trim(); +}; + + +// ────────────────────────── Hook mode ────────────────────────── + +const readStdinText = () => + new Promise((resolve) => { + if (process.stdin.isTTY) return resolve(""); + let text = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => (text += chunk)); + process.stdin.on("end", () => resolve(text)); + }); + + +// Find every mcp.json VS Code could load. +// Order matters — `collectServers` is "first wins". User-level wins because Agent Guard's flow writes the trusted +// entry there. +const findMcpJsonFiles = (cwd) => { + const paths = []; + const addIfExists = (p) => { if (existsSync(p)) paths.push(p); }; + + // 1. User-level mcp.json — trusted source, must win on conflict. + addIfExists(join(VSCODE_USER_DIR, "mcp.json")); + + // 2. Workspace + ancestors — walk upward, stopping at $HOME's parent or + // when dirname() can't go up any further (POSIX "/" or Windows "C:\"). + if (cwd) { + let dir = resolvePath(cwd); + const stopAt = resolvePath(HOME, ".."); + while (dir && dir !== stopAt) { + addIfExists(join(dir, ".vscode/mcp.json")); + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + return paths; +}; + + +const collectServers = (mcpJsonPaths) => { + const serversByName = Object.create(null); + for (const mcpJsonPath of mcpJsonPaths) { + const mcpJson = readJsoncFile(mcpJsonPath); + const serversInFile = mcpJson?.servers ?? {}; + for (const [serverName, serverEntry] of Object.entries(serversInFile)) { + if (!(serverName in serversByName)) { + serversByName[serverName] = { entry: serverEntry, sourcePath: mcpJsonPath }; + } + } + } + return serversByName; +}; + + +// Map a tool name like "mcp_chrome-devtoo_new_page" back to a server. +// VS Code sanitizes + truncates the server name, so we walk both strings +// side-by-side; the server whose prefix matches longest wins. +const findServerForTool = (toolName, serverNames) => { + if (!toolName?.startsWith(MCP_TOOL_PREFIX)) return null; + const toolSuffix = toolName.slice(MCP_TOOL_PREFIX.length); + + let bestName = null; + let bestLength = 0; + + for (const serverName of serverNames) { + const sanitized = serverName.replace(/[^A-Za-z0-9_-]/g, "_"); + const maxLen = Math.min(sanitized.length, toolSuffix.length); + + let matchedLen = 0; + while (matchedLen < maxLen && sanitized[matchedLen] === toolSuffix[matchedLen]) matchedLen++; + if (matchedLen === 0) continue; + + const fits = matchedLen === toolSuffix.length || toolSuffix[matchedLen] === "_"; + if (fits && matchedLen > bestLength) { + bestName = serverName; + bestLength = matchedLen; + } + } + return bestName; +}; + + +const isHttpUrl = (value) => { + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { return false; } +}; + +// Check a server's launch command against POLICY. Returns null on pass, +// or a short string saying which part of the policy failed. +const validateAgentGuard = (entry) => { + if (!entry || entry.command !== POLICY.command) { + return `command '${entry?.command ?? "(none)"}' must be '${POLICY.command}'`; + } + const args = Array.isArray(entry.args) ? entry.args : []; + + for (const required of POLICY.required_args) { + if (!args.includes(required)) return `missing required arg '${required}'`; + } + + const registryIdx = args.indexOf(POLICY.registry_arg); + if (registryIdx < 0 || registryIdx === args.length - 1) { + return `missing '${POLICY.registry_arg} ' pair`; + } + const registryValue = args[registryIdx + 1]; + if (!isHttpUrl(registryValue)) { + return `'${POLICY.registry_arg} ${registryValue}' is not an http(s) URL`; + } + return null; +}; + + +// Emit one audit-log line and exit. deny → stderr + exit 2. allow → silent exit 0. +const exitWith = ({ decision, reason, toolName = "", toolUseId = "", server = "" }) => { + audit({ event_type: "decision", tool_use_id: toolUseId, tool_name: toolName, server, decision, reason }); + if (decision === "deny") process.stderr.write(`${PRODUCT_NAME}: ${reason}\n`); + process.exit(decision === "deny" ? 2 : 0); +}; + + +const hookMode = async () => { + // Kill switch — checked FIRST, before reading stdin or parsing anything. + const forceDisable = process.env[FORCE_DISABLE_ENV]; + if (forceDisable?.toLowerCase() === "true") { + audit({ event_type: "force_disabled", env: FORCE_DISABLE_ENV, value: forceDisable, decision: "allow" }); + process.exit(0); + } + + const stdinText = await readStdinText(); + let request = {}; + try { request = parseJsonc(stdinText) ?? {}; } catch { /* leave empty if not JSON */ } + + const toolName = request.tool_name ?? ""; + const toolUseId = request.tool_use_id ?? ""; + const cwd = request.cwd ?? process.cwd(); + + // Non-MCP tools (run_in_terminal, read_file, …) are not our policy. + if (!toolName.startsWith(MCP_TOOL_PREFIX)) { + return exitWith({ decision: "allow", reason: "non-MCP tool, out of scope", toolName, toolUseId }); + } + + const servers = collectServers(findMcpJsonFiles(cwd)); + const server = findServerForTool(toolName, Object.keys(servers)); + + if (!server) { + return exitWith({ decision: "deny", reason: "server not found in mcp.json", toolName, toolUseId }); + } + + const failure = validateAgentGuard(servers[server].entry); + if (failure) { + return exitWith({ + decision: "deny", + reason: `server '${server}' does not match JFrog gateway shape (${failure})`, + toolName, toolUseId, server, + }); + } + return exitWith({ + decision: "allow", + reason: "npx + @jfrog/agent-guard + --registry ", + toolName, toolUseId, server, + }); +}; + + +// ────────────────────────── --register ────────────────────────── + +const SETTINGS_INDENT = 2; + +const readSettings = () => { + if (!existsSync(VSCODE_SETTINGS_PATH)) return {}; + try { return parseJsonc(readFileSync(VSCODE_SETTINGS_PATH, "utf8")) ?? {}; } + catch (err) { + process.stderr.write(`${PRODUCT_NAME}: cannot parse ${VSCODE_SETTINGS_PATH}: ${err.message}\nFix manually and rerun.\n`); + process.exit(1); + } +}; + + +const withHookEntry = (current) => { + const next = { ...current }; + const existing = next["chat.hookFilesLocations"]; + const locations = existing && typeof existing === "object" ? { ...existing } : {}; + locations[HOOK_CONFIG_TILDE] = true; + next["chat.hookFilesLocations"] = locations; + return next; +}; + +const updateSettings = (transform) => { + const currentText = existsSync(VSCODE_SETTINGS_PATH) ? readFileSync(VSCODE_SETTINGS_PATH, "utf8") : ""; + const nextText = JSON.stringify(transform(readSettings()), null, SETTINGS_INDENT) + "\n"; + if (currentText !== nextText) atomicWrite(VSCODE_SETTINGS_PATH, nextText); +}; + + +// Write the VS Code hooks-config JSON that chat.hookFilesLocations points at. +// Skip if the file already exists with byte-identical content. +const writeHookConfig = () => { + const payload = { + version: 1, + hooks: { + PreToolUse: [{ type: "command", command: HOOK_SCRIPT_PATH }], + }, + }; + const nextText = JSON.stringify(payload, null, 2) + "\n"; + const currentText = existsSync(HOOK_CONFIG_PATH) ? readFileSync(HOOK_CONFIG_PATH, "utf8") : ""; + if (currentText !== nextText) atomicWrite(HOOK_CONFIG_PATH, nextText); +}; + +// True if settings.json already has our hook-path under chat.hookFilesLocations. +const isHookRegistered = () => { + if (!existsSync(VSCODE_SETTINGS_PATH)) return false; + let parsed; + try { parsed = parseJsonc(readFileSync(VSCODE_SETTINGS_PATH, "utf8")) ?? {}; } + catch { return false; } + const locations = parsed["chat.hookFilesLocations"]; + return !!(locations && typeof locations === "object" && locations[HOOK_CONFIG_TILDE] === true); +}; + +// Re-register on every install / MDM heal. Short-circuits when already +// present so we never touch settings.json after the first install. +const register = () => { + writeHookConfig(); + if (isHookRegistered()) { + process.stdout.write(`${PRODUCT_NAME}: already registered in ${VSCODE_SETTINGS_PATH}, no changes\n`); + return; + } + updateSettings(withHookEntry); + process.stdout.write(`${PRODUCT_NAME}: registered in ${VSCODE_SETTINGS_PATH}\n`); +}; + +// ────────────────────────── entrypoint ────────────────────────── + +const arg = process.argv[2]; +if (arg === "--register") register(); +else if (arg === "--version") process.stdout.write(readVersion() + "\n"); +else { + hookMode().catch((err) => { + // Fail-closed: any unhandled error becomes a deny, never a bypass. + const reason = `unexpected error: ${err?.stack ?? err?.message ?? err}`; + audit({ event_type: "decision", server: "", decision: "deny", reason }); + process.stderr.write(`${PRODUCT_NAME}: ${reason}\n`); + process.exit(2); + }); +} diff --git a/agent-guard-hook/com.jfrog.agent-guard-hook.mobileconfig b/agent-guard-hook/com.jfrog.agent-guard-hook.mobileconfig new file mode 100644 index 0000000..ab5fe6e --- /dev/null +++ b/agent-guard-hook/com.jfrog.agent-guard-hook.mobileconfig @@ -0,0 +1,43 @@ + + + + + + PayloadDisplayName + JFrog Agent Guard Hook — VS Code Chat Hooks + PayloadIdentifier + com.jfrog.agent-guard-hook + PayloadType + Configuration + PayloadUUID + 1F9A4E20-2C5B-4B9E-8A8B-2C5BFAB10000 + PayloadVersion + 1 + PayloadOrganization + JFrog Ltd. + PayloadScope + System + + PayloadContent + + + PayloadType + com.microsoft.VSCode + PayloadIdentifier + com.jfrog.agent-guard-hook.vscode + PayloadUUID + 1F9A4E20-2C5B-4B9E-8A8B-2C5BFAB10001 + PayloadVersion + 1 + PayloadDisplayName + VS Code Chat Hooks Lock + ChatHooks + + + + + diff --git a/agent-guard-hook/install.mjs b/agent-guard-hook/install.mjs new file mode 100644 index 0000000..4ef6834 --- /dev/null +++ b/agent-guard-hook/install.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// JFrog Ltd. — cross-platform installer for the JFrog Agent Guard Hook. + +import { spawnSync } from "node:child_process"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, platform, tmpdir } from "node:os"; +import { join } from "node:path"; + +const PRODUCT_NAME = "agent-guard-hook"; +const HOOK_FILE = `${PRODUCT_NAME}.mjs`; +const VERSION_RE = /^\/\/\s*agent-guard-hook-version:\s*(.+)$/m; + +const HOME = homedir(); +const HOOK_DIR = join(HOME, ".vscode", "hooks"); +const HOOK_SCRIPT = join(HOOK_DIR, HOOK_FILE); +const AUDIT_LOG = join(HOOK_DIR, `${PRODUCT_NAME}.log`); + +const ART_HOST = "https://releases.jfrog.io/artifactory"; +const REPO = "coding-agents-generic"; + +const args = process.argv.slice(2); +const flag = (name) => args.includes(name); +const force = flag("--force"); + + +// Logging. +const log = (msg) => process.stdout.write(`==> ${msg}\n`); +const exitWithError = (msg) => { process.stderr.write(`!! ${msg}\n`); process.exit(1); }; + + +// Idempotent version check — read line 2 of the locally installed hook +// and compare against the staged archive's hook before overwriting. +const versionInFile = (path) => { + if (!existsSync(path)) return null; + const match = readFileSync(path, "utf8").match(VERSION_RE); + return match ? match[1].trim() : null; +}; + + +const fetchToFile = async (url, dest) => { + log(`download ${url}`); + const res = await fetch(url); + if (!res.ok) exitWithError(`HTTP ${res.status} for ${url}`); + const buf = Buffer.from(await res.arrayBuffer()); + writeFileSync(dest, buf); +}; + +const fetchText = async (url) => { + const res = await fetch(url); + if (!res.ok) exitWithError(`HTTP ${res.status} for ${url}`); + return (await res.text()).trim(); +}; + + +// Extract a .tgz archive using whichever extractor the OS provides. +const extractArchive = (archivePath, destDir) => { + log(`extract ${archivePath} → ${destDir}`); + const result = spawnSync("tar", ["-xzf", archivePath, "-C", destDir], { stdio: "inherit" }); + if (result.status !== 0) exitWithError("tar -xzf failed"); +}; + + +// Make the hook executable on POSIX. 0o755 = rwxr-xr-x: +// owner: read+write+execute (we need +x so VS Code can spawn it via the #!/usr/bin/env node shebang), +// group + others: read+execute (so `node` / `cat` / `tail` can inspect it). +// On Windows, chmod is a no-op — execute permission comes from the file extension, and VS Code launches the +// script through `node`. +const makeHookExecutable = (path) => { + if (platform() === "win32") return; // Windows ignores POSIX file modes + chmodSync(path, 0o755); +}; + +const installFiles = (stagedHookPath) => { + mkdirSync(HOOK_DIR, { recursive: true }); + if (existsSync(HOOK_SCRIPT)) { + const tmpPath = `${HOOK_SCRIPT}.${process.pid}.new`; + copyFileSync(stagedHookPath, tmpPath); + makeHookExecutable(tmpPath); + renameSync(tmpPath, HOOK_SCRIPT); + } else { + copyFileSync(stagedHookPath, HOOK_SCRIPT); + makeHookExecutable(HOOK_SCRIPT); + } + + if (!existsSync(AUDIT_LOG)) writeFileSync(AUDIT_LOG, ""); +}; + + +// Find the latest version from Artifactory's LATEST text file. +// LATEST file content is just the version string (e.g. "0.1.0"). +const resolveLatestVersion = async () => + fetchText(`${ART_HOST}/${REPO}/${PRODUCT_NAME}/LATEST`); + +const archiveUrl = (version) => + `${ART_HOST}/${REPO}/${PRODUCT_NAME}/${PRODUCT_NAME}-${version}.tgz`; + + +// ────────────────────────── install ────────────────────────── + +const cmdInstall = async () => { + log(`installing ${PRODUCT_NAME}`); + + // Stage everything in a temp dir so an aborted installation leaves no debris. + const stagingDir = mkdtempSync(join(tmpdir(), `${PRODUCT_NAME}-`)); + const version = await resolveLatestVersion(); + log(`latest version: ${version}`); + const archivePath = join(stagingDir, `${PRODUCT_NAME}-${version}.tgz`); + await fetchToFile(archiveUrl(version), archivePath); + + extractArchive(archivePath, stagingDir); + const stagedHookPath = join(stagingDir, HOOK_FILE); + if (!existsSync(stagedHookPath)) exitWithError(`archive missing ${HOOK_FILE}`); + + // Idempotent skip — if the version already on disk matches the staged one, do nothing + // (apart from a register call to heal settings.json). + const localVersion = versionInFile(HOOK_SCRIPT); + const stagedVersion = versionInFile(stagedHookPath); + if (localVersion && stagedVersion && localVersion === stagedVersion && !force) { + log(`already at ${localVersion}, skipping file copy`); + } else { + installFiles(stagedHookPath); + log(`installed version ${stagedVersion} at ${HOOK_SCRIPT}`); + } + + // Register (or re-register) the hook in VS Code's settings.json. + log("registering in VS Code settings.json"); + spawnSync(process.execPath, [HOOK_SCRIPT, "--register"], { stdio: "inherit" }); + + rmSync(stagingDir, { recursive: true, force: true }); + log("done"); +}; + + +// ────────────────────────── entrypoint ────────────────────────── + +cmdInstall().catch((err) => exitWithError(err?.stack ?? err?.message ?? String(err))); diff --git a/agent-guard-hook/poc/release.sh b/agent-guard-hook/poc/release.sh new file mode 100755 index 0000000..2ec7b0e --- /dev/null +++ b/agent-guard-hook/poc/release.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Engineer-local release fallback. The canonical release is the GH workflow +# (.github/workflows/agent-guard-hook-ci.yml). This exists for sanity checks +# and for ad-hoc dev archives before CI is wired. +# +# Usage: +# ./poc/release.sh version=0.0.0-local..g +# ./poc/release.sh --version 0.1.0 explicit version +# ./poc/release.sh --dry-run +# +# Env: +# AGENT_GUARD_HOOK_REPO default: coding-agents-generic/agent-guard-hook +# +# Needs: jf (logged in), tar, sed. + +set -euo pipefail + +DRY_RUN=0 +VERSION_OVERRIDE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --version) VERSION_OVERRIDE="${2:?--version needs a value}"; shift 2 ;; + *) echo "release.sh: unknown arg '$1'" >&2; exit 2 ;; + esac +done + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC_HOOK="${ROOT}/agent-guard-hook.mjs" + +# Mirror CI's versioning: real version preferred (--version), else compose a +# local dev version like the CI dev format so install.mjs sees a sane string. +if [[ -n "${VERSION_OVERRIDE}" ]]; then + VERSION="${VERSION_OVERRIDE}" +else + TS="$(date -u +%Y%m%d%H%M%S)" + SHA="$(git -C "${ROOT}" rev-parse --short HEAD 2>/dev/null || echo nogit)" + VERSION="0.0.0-local.${TS}.g${SHA}" +fi + +TGZ="agent-guard-hook-${VERSION}.tgz" +REPO="${AGENT_GUARD_HOOK_REPO:-coding-agents-generic/agent-guard-hook}" +# Top-level artefacts IT downloads. install.mjs is the entry point for the +# `curl ... | node` one-liner; the mobileconfig is the optional MDM payload. +TOP_LEVEL_FILES=(install.mjs com.jfrog.agent-guard-hook.mobileconfig) + +command -v tar >/dev/null 2>&1 || { echo "release.sh: tar not in PATH." >&2; exit 1; } +command -v sed >/dev/null 2>&1 || { echo "release.sh: sed not in PATH." >&2; exit 1; } +if [[ ${DRY_RUN} -eq 0 ]]; then + command -v jf >/dev/null 2>&1 || { echo "release.sh: jf (JFrog CLI) not in PATH." >&2; exit 1; } +fi + +DIST="${ROOT}/dist" +STAGE="${DIST}/stage" +rm -rf "${DIST}" && mkdir -p "${STAGE}" + +# Stage the .mjs into dist/stage/, sed-inject the version on line 2 of the +# COPY (never touch the committed source), then tar from the staging dir. +cp "${SRC_HOOK}" "${STAGE}/agent-guard-hook.mjs" +# BSD sed needs `-i ''`, GNU sed wants no arg — handle both. +if sed --version >/dev/null 2>&1; then + sed -i -E "2s|^// agent-guard-hook-version: .*$|// agent-guard-hook-version: ${VERSION}|" "${STAGE}/agent-guard-hook.mjs" +else + sed -i '' -E "2s|^// agent-guard-hook-version: .*$|// agent-guard-hook-version: ${VERSION}|" "${STAGE}/agent-guard-hook.mjs" +fi +echo "==> Packaging ${TGZ} (version=${VERSION})" +echo " line 2 of staged .mjs: $(sed -n '2p' "${STAGE}/agent-guard-hook.mjs")" + +tar -C "${STAGE}" -czf "${DIST}/${TGZ}" agent-guard-hook.mjs +echo "${VERSION}" > "${DIST}/LATEST" + +echo " -> ${DIST}/${TGZ}" +echo " -> ${DIST}/LATEST" + +if [[ ${DRY_RUN} -eq 1 ]]; then + echo + echo "==> Dry run, skipping upload." + echo " Would upload to ${REPO}:" + echo " ${VERSION}/${TGZ}" + echo " LATEST" + for f in "${TOP_LEVEL_FILES[@]}"; do echo " ${f}"; done + exit 0 +fi + +echo +echo "==> Uploading versioned archive -> ${REPO}/${VERSION}/" +jf rt upload "${DIST}/${TGZ}" "${REPO}/${VERSION}/" + +echo +echo "==> Refreshing top-level artefacts on ${REPO}/" +jf rt upload "${DIST}/LATEST" "${REPO}/LATEST" +for f in "${TOP_LEVEL_FILES[@]}"; do + jf rt upload "${ROOT}/${f}" "${REPO}/${f}" +done + +cat < Release ${VERSION} published to ${REPO}. + IT one-liner (no sudo — user-space install): + curl -fsSL https://\${ARTIFACTORY_HOST}/artifactory/${REPO}/install.mjs | node +EOF