From 4e35718d1b7bfcce92bc2b2e5c277cf3b7993ed1 Mon Sep 17 00:00:00 2001 From: yanivt Date: Tue, 26 May 2026 11:19:47 +0300 Subject: [PATCH 1/7] vscode Bypass --- .github/workflows/merge-mcp-gate-dev.yml | 90 ++++++ .github/workflows/release-mcp-gate.yml | 231 +++++++++++++++ .gitignore | 10 + mcp-gate/.jfrog-distribution.yml | 35 +++ mcp-gate/README.md | 352 +++++++++++++++++++++++ mcp-gate/VERSION | 1 + mcp-gate/bin/jfrog-mcp-gate.mjs | 212 ++++++++++++++ mcp-gate/bin/jfrog-setup-user.mjs | 127 ++++++++ mcp-gate/com.jfrog.mcp-gate.mobileconfig | 59 ++++ mcp-gate/install.ps1 | 141 +++++++++ mcp-gate/install.sh | 230 +++++++++++++++ mcp-gate/lib/config.mjs | 72 +++++ mcp-gate/poc/release.sh | 67 +++++ mcp-gate/uninstall.ps1 | 81 ++++++ mcp-gate/uninstall.sh | 111 +++++++ 15 files changed, 1819 insertions(+) create mode 100644 .github/workflows/merge-mcp-gate-dev.yml create mode 100644 .github/workflows/release-mcp-gate.yml create mode 100644 .gitignore create mode 100644 mcp-gate/.jfrog-distribution.yml create mode 100644 mcp-gate/README.md create mode 100644 mcp-gate/VERSION create mode 100755 mcp-gate/bin/jfrog-mcp-gate.mjs create mode 100755 mcp-gate/bin/jfrog-setup-user.mjs create mode 100644 mcp-gate/com.jfrog.mcp-gate.mobileconfig create mode 100644 mcp-gate/install.ps1 create mode 100755 mcp-gate/install.sh create mode 100644 mcp-gate/lib/config.mjs create mode 100755 mcp-gate/poc/release.sh create mode 100644 mcp-gate/uninstall.ps1 create mode 100755 mcp-gate/uninstall.sh diff --git a/.github/workflows/merge-mcp-gate-dev.yml b/.github/workflows/merge-mcp-gate-dev.yml new file mode 100644 index 0000000..147790e --- /dev/null +++ b/.github/workflows/merge-mcp-gate-dev.yml @@ -0,0 +1,90 @@ +# Dev publish on every merge to main. Builds a pre-release install package +# `mcp-gate--dev..tgz` (run_number is GitHub's +# monotonic counter per workflow, e.g. 42 = the 42nd run) and uploads +# it to entplus dev master generic for internal testing. +# +# Engineers install a dev package by downloading the .tgz directly from +# entplus and passing --package: +# curl -sSfLO https://entplus.jfrog.io/artifactory//jfrog-mcp-gate/v0.1.0-dev.42/mcp-gate-0.1.0-dev.42.tgz +# sudo ./install.sh --package ./mcp-gate-0.1.0-dev.42.tgz +# +# Production releases go through release-mcp-gate.yml (manual trigger). + +name: Dev publish mcp-gate + +on: + push: + branches: + - main + paths: + - "mcp-gate/**" + - ".github/workflows/merge-mcp-gate-dev.yml" + +permissions: + id-token: write + contents: read + +env: + JF_URL: ${{ vars.JF_URL }} + JF_OIDC_PROVIDER: ${{ vars.JF_OIDC_PROVIDER }} + JF_OIDC_AUDIENCE: ${{ vars.JF_OIDC_AUDIENCE }} + JF_PROJECT: jfml + +jobs: + publish-dev: + name: Publish dev package + runs-on: devf-dind-amd-scale-set + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + + # Dev version = -dev., e.g. 0.1.0-dev.42. + # run_number is GitHub's per-workflow counter; it goes up by 1 each + # run, so newer dev packages always sort higher than older ones. + - name: Compute dev version + id: version + run: | + set -e + BASE_VERSION="$(cat mcp-gate/VERSION)" + DEV_VERSION="${BASE_VERSION}-dev.${{ github.run_number }}" + echo "DEV_VERSION=${DEV_VERSION}" >> "$GITHUB_ENV" + echo "Building dev version: ${DEV_VERSION}" + + - name: Install build tools + id: install-tools + uses: JFROG/install-tools@master + with: + install-ngci: 'true' + + - name: Resolve dev generic repo + id: pre-build + uses: JFROG/next-gen-ci-pre-build@v6.0.1 + with: + project: jfml + service-name: jfrog-mcp-gate + short-service-name: jfmcpg + generic: "true" + debug: "true" + + - name: Build install package + run: | + set -e + cd mcp-gate + mkdir -p dist + tar -czf "dist/mcp-gate-${DEV_VERSION}.tgz" bin lib VERSION + ls -la dist/ + + - name: Upload dev package to entplus + env: + JF_ACCESS_TOKEN: ${{ steps.install-tools.outputs.oidc-token }} + TARGET_REPO: ${{ fromJSON(steps.pre-build.outputs.metadata).repositories.generic.deploy }} + run: | + set -e + DEST="${TARGET_REPO}/jfrog-mcp-gate/v${DEV_VERSION}/" + echo "==> Uploading mcp-gate-${DEV_VERSION}.tgz -> ${DEST}" + jfrog rt upload --fail-no-op --quiet --flat=true \ + --project="${JF_PROJECT}" \ + "mcp-gate/dist/mcp-gate-${DEV_VERSION}.tgz" "${DEST}" diff --git a/.github/workflows/release-mcp-gate.yml b/.github/workflows/release-mcp-gate.yml new file mode 100644 index 0000000..e1c9716 --- /dev/null +++ b/.github/workflows/release-mcp-gate.yml @@ -0,0 +1,231 @@ +# Release jfrog-mcp-gate to Artifactory. +# Edge path: releases.jfrog.io/jfrog-cli-plugins/jfrog-mcp-gate/... +# +# Prereqs (one-time, by JFrog CI infra): +# - Org GitHub vars JF_URL, JF_OIDC_PROVIDER, JF_OIDC_AUDIENCE. +# - Service "jfrog-mcp-gate" (short "jfmcpg") registered with +# next-gen-ci-pre-build under project jfml. + +name: Release jfrog-mcp-gate + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g. 0.1.1). Reads mcp-gate/VERSION if empty.' + required: false + type: string + promote_to_latest: + description: 'Also copy this version into jfrog-cli-plugins/jfrog-mcp-gate/latest/ on releases.jfrog.io.' + required: false + default: true + type: boolean + +concurrency: + group: jfrog-mcp-gate-release + cancel-in-progress: false + +permissions: + id-token: write + contents: read + +env: + JF_URL: ${{ vars.JF_URL }} + JF_OIDC_PROVIDER: ${{ vars.JF_OIDC_PROVIDER }} + JF_OIDC_AUDIENCE: ${{ vars.JF_OIDC_AUDIENCE }} + JF_PROJECT: jfml + +jobs: + # 1. Pre-Build: resolve dev generic repo + build metadata. + pre-build: + name: Pre-Build + runs-on: devf-dind-amd-scale-set + timeout-minutes: 15 + outputs: + metadata: ${{ steps.pre-build.outputs.metadata }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.ref }} + + - name: Generate build metadata + id: pre-build + uses: JFROG/next-gen-ci-pre-build@v6.0.1 + with: + project: jfml + service-name: jfrog-mcp-gate + short-service-name: jfmcpg + generic: "true" + validate-dev-repos: "true" + dev-repos-timeout-minutes: "10" + dev-repos-check-interval-seconds: "10" + debug: "true" + + # 2. Build & Upload: tar bin/+lib/+VERSION, push to dev generic on entplus. + build-and-upload: + name: Build & Upload + needs: pre-build + runs-on: devf-dind-amd-scale-set + timeout-minutes: 15 + outputs: + version: ${{ steps.version.outputs.version }} + env: + TARGET_REPO: ${{ fromJSON(needs.pre-build.outputs.metadata).repositories.generic.deploy }} + BUILD_NAME: ${{ fromJSON(needs.pre-build.outputs.metadata).service_name }}-release-${{ fromJSON(needs.pre-build.outputs.metadata).build_type }} + BUILD_NUMBER: ${{ fromJSON(needs.pre-build.outputs.metadata).build_number }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + + - name: Resolve version + id: version + run: | + set -e + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION="$(cat mcp-gate/VERSION)" + fi + VERSION="${VERSION#v}" + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "Releasing jfrog-mcp-gate version: ${VERSION}" + + - name: Install build tools + id: install-tools + uses: JFROG/install-tools@master + with: + install-ngci: 'true' + + - name: Build install package + run: | + set -e + cd mcp-gate + mkdir -p dist + TGZ="mcp-gate-${VERSION}.tgz" + tar -czf "dist/${TGZ}" bin lib VERSION + echo "${VERSION}" > dist/LATEST + ls -la dist/ + + - name: Upload artefacts to Artifactory + env: + JF_ACCESS_TOKEN: ${{ steps.install-tools.outputs.oidc-token }} + run: | + set -e + BASE="${TARGET_REPO}/jfrog-mcp-gate" + + echo "==> Versioned install package -> ${BASE}/v${VERSION}/" + jfrog rt upload --fail-no-op --quiet --flat=true \ + --build-name="${BUILD_NAME}" \ + --build-number="${BUILD_NUMBER}" \ + --project="${JF_PROJECT}" \ + "mcp-gate/dist/mcp-gate-${VERSION}.tgz" "${BASE}/v${VERSION}/" + + echo "==> Top-level artefacts -> ${BASE}/" + for f in install.sh uninstall.sh install.ps1 uninstall.ps1 com.jfrog.mcp-gate.mobileconfig; do + jfrog rt upload --fail-no-op --quiet --flat=true \ + --build-name="${BUILD_NAME}" \ + --build-number="${BUILD_NUMBER}" \ + --project="${JF_PROJECT}" \ + "mcp-gate/${f}" "${BASE}/${f}" + done + jfrog rt upload --fail-no-op --quiet --flat=true \ + --build-name="${BUILD_NAME}" \ + --build-number="${BUILD_NUMBER}" \ + --project="${JF_PROJECT}" \ + "mcp-gate/dist/LATEST" "${BASE}/LATEST" + + - name: Publish build info + env: + JF_ACCESS_TOKEN: ${{ steps.install-tools.outputs.oidc-token }} + run: | + set -e + jfrog rt build-publish \ + "${BUILD_NAME}" \ + "${BUILD_NUMBER}" \ + --project="${JF_PROJECT}" + + # 3. Post-Build: wrap the uploaded build in a signed release bundle and + # promote it to the "release" environment. + post-build: + name: Post-Build + needs: [pre-build, build-and-upload] + runs-on: devf-dind-amd-scale-set + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + + - name: Promote release artefacts + uses: JFROG/next-gen-ci-post-build@v3.3.0 + with: + # Manual mode (no `metadata:`) keeps a fixed bundle name across + # re-dispatches; new versions just stack on top of the same bundle. + bundle-name: jfrog-mcp-gate-release + version: ${{ needs.build-and-upload.outputs.version }} + project: ${{ env.JF_PROJECT }} + builds: '[{"name":"jfrog-mcp-gate-release","number":"${{ fromJSON(needs.pre-build.outputs.metadata).build_number }}"}]' + target-environment: release + create-final-tag: 'false' + + # 4. Distribute: mirror to releases.jfrog.io/jfrog-cli-plugins/. + distribute: + name: Distribute to release edges + needs: [pre-build, build-and-upload, post-build] + runs-on: devf-dind-amd-scale-set + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + + - name: Distribute jfrog-mcp-gate to release edges + uses: JFROG/next-gen-ci-distribution@v2.3.0 + with: + metadata: ${{ needs.pre-build.outputs.metadata }} + service: jfrog-mcp-gate + version: ${{ needs.build-and-upload.outputs.version }} + project: jfml + distribution-type: onprem + distribution-manifest: mcp-gate/.jfrog-distribution.yml + # Package is plain text/JS - skip Xray scan + clamav to keep + # the run fast. Build-info evidence is still attached. + skip-scan: 'true' + skip-clamav: 'true' + + # 5. Promote to "latest" on releases.jfrog.io (opt-in, default ON). + promote-latest: + name: Promote to latest on releases.jfrog.io + if: ${{ inputs.promote_to_latest == true }} + needs: [build-and-upload, post-build, distribute] + runs-on: devf-dind-amd-scale-set + 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@master + with: + install-ngci: 'true' + + - name: Copy versioned install package to latest/ on releases.jfrog.io + env: + JF_ACCESS_TOKEN: ${{ steps.install-tools.outputs.oidc-token }} + run: | + set -e + echo "Copying v${VERSION} to latest/ on releases.jfrog.io..." + jf rt cp --fail-no-op \ + "jfrog-cli-plugins/jfrog-mcp-gate/v${VERSION}/(*)" \ + "jfrog-cli-plugins/jfrog-mcp-gate/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/mcp-gate/.jfrog-distribution.yml b/mcp-gate/.jfrog-distribution.yml new file mode 100644 index 0000000..0b54014 --- /dev/null +++ b/mcp-gate/.jfrog-distribution.yml @@ -0,0 +1,35 @@ +# Lists the artifacts bundled into a signed release bundle and mirrored to releases.jfrog.io. Read by .github/workflows/release-mcp-gate.yml. +# is filled in at runtime from the workflow's `version:` input + +artifacts: + # Versioned install package (immutable). + - type: generic + path: jfrog-mcp-gate/v/mcp-gate-.tgz + target: + repository: jfrog-cli-plugins + + # Top-level files (overwritten each release - what IT downloads). + - type: generic + path: jfrog-mcp-gate/install.sh + target: + repository: jfrog-cli-plugins + - type: generic + path: jfrog-mcp-gate/uninstall.sh + target: + repository: jfrog-cli-plugins + - type: generic + path: jfrog-mcp-gate/install.ps1 + target: + repository: jfrog-cli-plugins + - type: generic + path: jfrog-mcp-gate/uninstall.ps1 + target: + repository: jfrog-cli-plugins + - type: generic + path: jfrog-mcp-gate/com.jfrog.mcp-gate.mobileconfig + target: + repository: jfrog-cli-plugins + - type: generic + path: jfrog-mcp-gate/LATEST + target: + repository: jfrog-cli-plugins diff --git a/mcp-gate/README.md b/mcp-gate/README.md new file mode 100644 index 0000000..1b5512b --- /dev/null +++ b/mcp-gate/README.md @@ -0,0 +1,352 @@ +# jfrog-mcp-gate + +A VS Code `PreToolUse` hook that allows only MCP tool calls whose server is launched through the JFrog gateway (`npx --yes --registry <…jfrog…> @jfrog/agent-guard …`). Everything else is denied. + +Ships from `releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/` (the same trust boundary the hook itself enforces). + +### Platform support + +The hook code (`bin/*.mjs`, `lib/config.mjs`) is plain Node ≥ 20 and runs on every platform. Only the installer and the per-user service flavor change per OS. + +| | macOS | Linux | Windows | +| --- | --- | --- | --- | +| Hook code | yes | yes | yes | +| Installer | `install.sh` | `install.sh` (same script, auto-detects `uname`) | `install.ps1` (elevated PowerShell) | +| Per-user service | LaunchAgent | `systemd --user` timer (`OnBootSec=10s, OnUnitActiveSec=60s`) | Scheduled Task (at logon + every 1 min) | +| Install root | `/usr/local/jfrog/mcp-gate/` | `/usr/local/jfrog/mcp-gate/` | `%ProgramFiles%\JFrog\mcp-gate\` | +| Audit log | `/var/log/jfrog-mcp-gate.log` (0666) | `/var/log/jfrog-mcp-gate.log` (0666) | `%ProgramData%\JFrog\Logs\jfrog-mcp-gate.log` (Users:Modify) | +| Setup-tick logs | `/Library/Logs/jfrog-mcp-gate/setup.*.log` | `journalctl --user -u jfrog-mcp-user-setup.service` | Task Scheduler history | +| Per-user state | `~/.jfrog/mcp-gate/` | `~/.jfrog/mcp-gate/` | `%USERPROFILE%\.jfrog\mcp-gate\` | +| ChatHooks policy | `com.jfrog.mcp-gate.mobileconfig` | `/etc/vscode/policy.json` | `HKLM\Software\Policies\Microsoft\VSCode\ChatHooks` (Group Policy / Intune) | + +> **Anti-tamper**: The hook config and settings.json entry are heal-on-tick — if a user deletes them the per-user scheduler restores them within ≤60s and writes an `event_type=reseed` audit line. The hook binary itself sits in a root-owned install root that requires sudo/admin to modify; MDM heals that on its check-in. + +## How the pipeline works + +``` + engineer machine GitHub entplus.jfrog.io releases.jfrog.io laptop + ───────────────── ────── ──────────────── ───────────────── ────── + edit code + VERSION + → git push PR build (optional) ─ ─ ─ + ↓ + merge to main + ↓ + merge-mcp-gate-dev.yml → dev-master-generic-local/ ─ ─ + (fires automatically) jfrog-mcp-gate/v-dev./ + mcp-gate--dev..tgz + (engineers can pull this for staging tests) + + release-mcp-gate.yml → dev-master-generic-local/... jfrog-cli-plugins/ IT's MDM re-runs + (manual: bump VERSION, → release bundle promote jfrog-mcp-gate/v/ install.sh on its + click "Run workflow") → distribute to edges mcp-gate-.tgz schedule and pulls + + install.sh, uninstall.sh, the new version + LATEST, .mobileconfig +``` + +Two workflows: + +| Workflow | Trigger | Lands on | +| --- | --- | --- | +| `merge-mcp-gate-dev.yml` | Every merge to `main` | entplus dev master generic (`dev-master-generic-local/jfrog-mcp-gate/v-dev./`) | +| `release-mcp-gate.yml` | Manual (`workflow_dispatch`) | `releases.jfrog.io/jfrog-cli-plugins/jfrog-mcp-gate/v/` and the top-level `install.sh` / `uninstall.sh` / `LATEST` / `.mobileconfig` | + +Until JFrog CI infra onboards the repo, `poc/release.sh` is the local-engineer fallback (uses `jf rt upload` directly). + +## Files in this repo + +### Top level — what IT and laptops consume + +| File | Owner | Purpose | +| --- | --- | --- | +| `install.sh` | IT, MDM (macOS + Linux). Engineers locally. | Auto-detects `uname` and dispatches macOS vs. Linux. Default: download the latest install package (`mcp-gate-.tgz`) from Artifactory. With `--package `: install from a local `.tgz` file (engineer testing). | +| `uninstall.sh` | IT, support (macOS + Linux) | Removes everything `install.sh` wrote + per-user state for the logged-in user. Audit log preserved. | +| `install.ps1` | IT, MDM (Windows). Engineers locally. | PowerShell equivalent of `install.sh`. Must be run from an elevated PowerShell. Same `-Package` flag for local testing. | +| `uninstall.ps1` | IT, support (Windows) | PowerShell equivalent of `uninstall.sh`. | +| `com.jfrog.mcp-gate.mobileconfig` | IT, MDM (macOS only) | macOS configuration profile. Sets `ChatHooks=true` so users can't disable VS Code hooks. Pushed via Jamf/Intune/Munki. | +| `VERSION` | Engineer | The only metadata you edit when cutting a release. | +| `.jfrog-distribution.yml` | CI | Required by `JFROG/next-gen-ci-distribution` in the release workflow. Lists the artefacts to bundle into a release bundle and push to the edge. | + +### `bin/` — the binaries that get installed + +| File | Purpose | +| --- | --- | +| `bin/jfrog-mcp-gate.mjs` | **The hook.** VS Code spawns it with a `PreToolUse` payload on every chat tool call. Reads `mcp.json`, validates the launch command against `lib/config.mjs`, exits `0` (allow) or `2` (deny). No flags — the hook is a pure stdin→exit-code program. | +| `bin/jfrog-setup-user.mjs` | **Per-user setup.** Run by the per-user service at login + every 60s. Writes `~/.jfrog/mcp-gate/vscode-hooks.json`, adds the `chat.hookFilesLocations` entry to `settings.json`, applies macOS locks. One flag: `--clean` (reverse of a tick — used by uninstallers). | + +### `lib/` — the shared module + +| File | Purpose | +| --- | --- | +| `lib/config.mjs` | Single shared module: policy (`POLICY`), OS-specific paths, hook-config payload, JSONC helpers, audit logger. Both binaries import only this file. | + +### `poc/` — engineer-local fallback (delete once CI infra is in place) + +| File | Purpose | +| --- | --- | +| `poc/release.sh` | Builds the install package (`mcp-gate-.tgz`) and `jf rt upload`s it to `JFROG_MCP_GATE_REPO` (default `jfrog-cli-plugins/jfrog-mcp-gate`). Same outputs as the GH Action — produces `dist/mcp-gate-.tgz` and `dist/LATEST`. Supports `--dry-run` to build the file without uploading. | + +### `.github/workflows/` — at the repo root + +| File | Purpose | +| --- | --- | +| `merge-mcp-gate-dev.yml` | Auto-publishes the dev install package (`mcp-gate--dev..tgz`) to entplus dev master generic on every merge to main. | +| `release-mcp-gate.yml` | Manual-trigger production release. Pre-Build → Build & Upload → Post-Build (release bundle + promote) → Distribute → Promote-Latest. | + +## What gets installed on each laptop + +### macOS + +Root-owned (by `install.sh`): + +``` +/usr/local/jfrog/mcp-gate/bin/jfrog-mcp-gate.mjs the hook +/usr/local/jfrog/mcp-gate/bin/jfrog-setup-user.mjs per-user setup +/usr/local/jfrog/mcp-gate/lib/config.mjs policy + helpers +/usr/local/jfrog/mcp-gate/VERSION +/Library/LaunchAgents/com.jfrog.mcp-user-setup.plist +/var/log/jfrog-mcp-gate.log audit log (0666) +``` + +MDM-pushed (from `.mobileconfig`): +`/Library/Managed Preferences/com.microsoft.VSCode.plist` → `ChatHooks=true`. + +Per-user state (heal-on-tick by the LaunchAgent every 60s): + +``` +~/.jfrog/mcp-gate/vscode-hooks.json +chat.hookFilesLocations entry in ~/Library/Application Support/Code/User/settings.json +``` + +### Linux + +Root-owned (by `install.sh`): + +``` +/usr/local/jfrog/mcp-gate/... same layout as macOS +/etc/systemd/user/jfrog-mcp-user-setup.service per-user oneshot +/etc/systemd/user/jfrog-mcp-user-setup.timer OnBootSec=10s, OnUnitActiveSec=60s +/var/log/jfrog-mcp-gate.log audit log (0666) +``` + +MDM-pushed: `/etc/vscode/policy.json` containing `{"ChatHooks": true}`. + +Per-user state (no kernel-level lock): + +``` +~/.jfrog/mcp-gate/vscode-hooks.json +chat.hookFilesLocations entry in ~/.config/Code/User/settings.json +``` + +### Windows + +Admin-owned (by `install.ps1`): + +``` +%ProgramFiles%\JFrog\mcp-gate\bin\jfrog-mcp-gate.mjs the hook +%ProgramFiles%\JFrog\mcp-gate\bin\jfrog-setup-user.mjs per-user setup +%ProgramFiles%\JFrog\mcp-gate\lib\config.mjs policy + helpers +%ProgramFiles%\JFrog\mcp-gate\VERSION +Scheduled Task "JFrogMcpUserSetup" at logon + every 1 min +%ProgramData%\JFrog\Logs\jfrog-mcp-gate.log audit log (Users:Modify) +``` + +MDM-pushed: `HKLM\Software\Policies\Microsoft\VSCode\ChatHooks` (REG_DWORD, 1) — via Group Policy / Intune. + +Per-user state (no kernel-level lock): + +``` +%USERPROFILE%\.jfrog\mcp-gate\vscode-hooks.json +chat.hookFilesLocations entry in %APPDATA%\Code\User\settings.json +``` + +## Demo outcomes + +The hook walks every `mcp.json` VS Code can load (user profile + workspace + ancestor `.vscode/mcp.json`) and validates each launch command against `lib/config.mjs`. + +| Demo case | Expected outcome | +| --- | --- | +| MCP through the gateway: `npx --yes --registry @jfrog/agent-guard …` | **ALLOW**, audit reason `npx + @jfrog/agent-guard + --registry `. | +| MCP launched outside the gateway: `"command": "node"` | **DENY**, audit reason `… (command 'node' must be 'npx')`. | +| MCP with the old `@jfrog/mcp-gateway` | **DENY**, audit reason `… (missing required arg '@jfrog/agent-guard')`. | +| Extension-registered MCP (e.g. bundled PostgreSQL MCP) | **DENY**, audit reason `server not found in mcp.json - extension-registered MCPs are not gateway-served`. | +| Non-MCP tools (`run_in_terminal`, `read_file`) | **ALLOW**, audit reason `non-MCP tool, out of scope`. | + +## Enforcement + +| Bypass attempt | Outcome | +| --- | --- | +| User sets `chat.useHooks=false` | MDM `ChatHooks=true` policy overrides. | +| User deletes `chat.hookFilesLocations` from `settings.json` | Setup-user re-adds it ≤60s (`event_type=reseed`). | +| User `rm ~/.jfrog/mcp-gate/vscode-hooks.json` | Works once; setup-user rewrites it on the next tick (≤60s). | +| User deletes the hook binary in `/usr/local/jfrog/…` (or Windows equivalent) | Requires sudo/admin. MDM reruns `install.sh` on next check-in. | +| User unloads the LaunchAgent / disables the Scheduled Task / stops the timer | Requires sudo/admin. MDM re-registers it on next check-in. | + +## Audit log + +Every decision is one JSON line in `/var/log/jfrog-mcp-gate.log`. Fields: `ts`, `product`, `version`, `event_type` (`decision` / `reseed` / `setup_user_tick`), `tool_use_id`, `tool_name`, `server`, `decision` (`allow` / `deny`), `reason`. + +```sh +tail -f /var/log/jfrog-mcp-gate.log | jq -c 'select(.event_type=="decision")' +``` + +## Adjusting the policy + +`lib/config.mjs` is the single source of truth: + +```js +export const POLICY = { + command: "npx", + required_args: ["--yes", "@jfrog/agent-guard"], + registry_arg: "--registry", +}; +``` + +We require the `--registry ` pair (Agent Guard can't run without +it) but we don't restrict the URL value — different customers point at +different repos. + +After editing, bump `VERSION` and trigger the release workflow. + +--- + +## Three flows + +### Flow 1 — test it locally in VS Code (engineer) + +Six steps. Same `install.sh` IT runs in production, just pointed at a locally-built `.tgz` file instead of Artifactory. + +```sh +cd /Users/yanivt/Jfrog/vscode-plugin/mcp-gate + +# 1. Clean slate (remove any previous install, including locks). +sudo ./uninstall.sh + +# 2. Build the install package. Produces dist/mcp-gate-.tgz. +./poc/release.sh --dry-run + +# 3. Install from that local package. You'll be asked for your sudo password. +sudo ./install.sh --package dist/mcp-gate-0.1.0.tgz + +# 4. In a second terminal, watch the audit log: +tail -f /var/log/jfrog-mcp-gate.log | jq -c 'select(.event_type=="decision")' + +# 5. Open VS Code → Copilot Chat → trigger MCP tool calls: +# - Allowed: any MCP server you've configured to launch via +# "command": "npx", "args": ["--yes", "--registry", +# "", "@jfrog/agent-guard", ...] +# - Denied: anything else (extension-registered MCP, "command": "node", +# missing "--registry " pair, missing @jfrog/agent-guard). + +# 6. Tamper test (verifies the heal-on-tick scheduler works): +rm ~/.jfrog/mcp-gate/vscode-hooks.json +# Within 60s the LaunchAgent rewrites the file and writes +# `event_type=reseed` to the audit log. + +# 7. Clean up when done. +sudo ./uninstall.sh +``` + +Quick smoke without going through VS Code — feed the hook a fake VS Code payload: + +```sh +echo '{"tool_name":"mcp_chrome-devtools-mcp_new_page","cwd":"'$PWD'"}' \ + | node bin/jfrog-mcp-gate.mjs && echo "ALLOW" || echo "DENY" +tail -1 /var/log/jfrog-mcp-gate.log | jq . +``` + +### Flow 2 — release a new version (engineer) + +```sh +# 1. (Optional) Edit code, e.g. tweak the policy in lib/config.mjs. +# 2. Bump VERSION (the only metadata you change). +echo "0.1.1" > mcp-gate/VERSION +# 3. Commit + push. +git commit -am "mcp-gate: 0.1.1 - widen registry regex" +git push +``` + +What happens automatically and what's manual: + +- **On merge to `main`** → `merge-mcp-gate-dev.yml` fires. Publishes `mcp-gate-0.1.1-dev..tgz` to entplus dev master generic. Engineers can pull from there for staging tests. +- **Manual when ready to ship** → GitHub → Actions → `Release jfrog-mcp-gate` → Run workflow (`promote_to_latest: true`). Ships `0.1.1` to `releases.jfrog.io/jfrog-cli-plugins/jfrog-mcp-gate/v0.1.1/` and refreshes the top-level files IT downloads. + +POC fallback (only until CI infra is onboarded): + +```sh +cd mcp-gate +./poc/release.sh --dry-run # build only +./poc/release.sh # actually push to JFROG_MCP_GATE_REPO via `jf` +``` + +### Flow 3 — deploy to N laptops (IT) + +Two steps per laptop, wrapped by Jamf/Intune/Munki/Group-Policy. **The OS-specific bit is only the policy push + the one-liner**; the rest (Artifactory, versioning, rollback) is identical across all three. + +#### macOS + +```sh +# 1. Push com.jfrog.mcp-gate.mobileconfig via Jamf/Intune (sets ChatHooks=true). +# 2. Run the installer (typically scheduled to re-run every 30 min). +curl -sSfL https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/install.sh \ + | sudo bash +``` + +#### Linux + +```sh +# 1. Write the ChatHooks=true VS Code policy. +sudo install -m 0644 /dev/stdin /etc/vscode/policy.json <<<'{"ChatHooks": true}' +# 2. Run the installer (same script auto-detects Linux). +curl -sSfL https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/install.sh \ + | sudo bash +``` + +#### Windows + +```powershell +# 1. Push ChatHooks=1 via Group Policy (preferred) or directly: +reg add HKLM\Software\Policies\Microsoft\VSCode /v ChatHooks /t REG_DWORD /d 1 /f +# 2. Run the installer from an elevated PowerShell. +iwr -useb https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/install.ps1 | iex +``` + +#### Common to all three + +Updates are automatic — each re-run reads `/LATEST` from Artifactory and reinstalls only if the version changed. To roll back or stage a specific build, download the `.tgz` directly and pass `--package`: + +```sh +# macOS + Linux — pin to v0.1.0 +curl -sSfLO https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/v0.1.0/mcp-gate-0.1.0.tgz +sudo ./install.sh --package ./mcp-gate-0.1.0.tgz + +# Windows — same idea +iwr -useb https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/v0.1.0/mcp-gate-0.1.0.tgz -OutFile mcp-gate-0.1.0.tgz +.\install.ps1 -Package .\mcp-gate-0.1.0.tgz +``` + +Uninstall a laptop: + +```sh +# macOS + Linux +curl -sSfL https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/uninstall.sh \ + | sudo bash + +# Windows (elevated PowerShell) +iwr -useb https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/uninstall.ps1 | iex +``` + +Dev install packages live under `entplus.jfrog.io/.../dev-master-generic-local/jfrog-mcp-gate/v-dev./`. To stage one, download the `.tgz` from there and pass `--package`/`-Package` as above. + +## Prerequisites + +- macOS (Apple Silicon or Intel), Linux with systemd, or Windows 10+. +- VS Code ≥ 1.109 (`ChatHooks` enterprise policy shipped in 1.109). +- Node.js ≥ 20 on `PATH` (`node.exe` on Windows). +- macOS: nothing extra. Linux: `tar`, `curl`, `systemd --user`. Windows: PowerShell 5.1+ (or PowerShell 7), `tar.exe` (ships with Windows 10+). + +## Deferred + +- **Filesystem-level anti-tamper.** Today the user-level files (`~/.jfrog/mcp-gate/vscode-hooks.json` and the `chat.hookFilesLocations` entry in `settings.json`) are healed by the per-user scheduler every 60s, so a deletion only opens a ≤60s bypass window. Adding `chflags uchg` (macOS), `chattr +i` (Linux), or NTFS DENY ACLs would slow casual tampering, but each would require root/admin to apply, which a user-mode setup process can't do — so the heal-on-tick model is the cross-platform defense. +- **Non-default VS Code profiles.** The hook scans only the default profile's `mcp.json` (`/mcp.json`) plus workspace `.vscode/mcp.json` files. If a user creates a named profile (`/profiles//mcp.json`) and runs MCP servers from it, those servers won't be found → all their tool calls get denied. Rare in practice; we'll wire up profile discovery if it bites someone. +- **Validated on real Linux + Windows boxes.** The installers were authored on macOS. Linux + Windows runs need a smoke test pass before IT picks them up. +- **Signed `.pkg` (macOS) and signed `.msi` (Windows).** Today's distribution is the raw `.tgz` install package + the shell/PowerShell scripts; signed installer bundles are the natural next step once CI infra is in place. diff --git a/mcp-gate/VERSION b/mcp-gate/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/mcp-gate/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/mcp-gate/bin/jfrog-mcp-gate.mjs b/mcp-gate/bin/jfrog-mcp-gate.mjs new file mode 100755 index 0000000..c67ed00 --- /dev/null +++ b/mcp-gate/bin/jfrog-mcp-gate.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +// jfrog-mcp-gate — VS Code PreToolUse hook. +// +// On every chat tool call, VS Code spawns this script, pipes a JSON +// payload into its stdin, and reads our exit code: +// exit 0 = allow the tool call +// exit 2 = deny the tool call +// +// What this script does: +// Step 1. Read VS Code's payload from stdin. +// Step 2. Find every mcp.json VS Code could load +// (user-level + workspace + ancestor folders). +// Step 3. Merge them into one server list (user-level wins on conflict). +// Step 4. Figure out which server the tool call came from. +// Step 5. Validate that server's launch command against the policy. +// Step 6. Write one audit-log line and exit. + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve as resolvePath } from "node:path"; + +import { + POLICY, + PRODUCT_NAME, + VSCODE_USER_DIR, + audit, + parseJsonc, +} from "../lib/config.mjs"; + +// Constants +const HOME = homedir(); +const MCP_TOOL_PREFIX = "mcp_"; + + +// Step 1. Read VS Code's JSON payload from stdin. +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)); + }); + + +// Step 2. Find every mcp.json VS Code could load. +// Order matters — `collectServers` below is "first wins". We list the user-level mcp.json FIRST because Agent Guard's +// flow writes the trusted server entry there, and we want that entry to be the one we validate against +// `cwd` is the workspace folder VS Code told us about. After the user-level file we walk upward from `cwd` toward +// $HOME, picking up any `.vscode/mcp.json` along the way. +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 until we hit $HOME or "/". + if (cwd) { + let dir = resolvePath(cwd); + const stopAt = resolvePath(HOME, ".."); + while (dir && dir !== "/" && dir !== stopAt) { + addIfExists(join(dir, ".vscode/mcp.json")); + const parent = dirname(dir); + if (parent === dir) break; // reached filesystem root + dir = parent; + } + } + return paths; +}; + + +// Read + parse a JSONC file. Returns null on any error so the caller +// can skip an unparseable mcp.json without crashing the hook. +const readMcpJson = (path) => { + try { return parseJsonc(readFileSync(path, "utf8")); } catch { return null; } +}; + + +// Step 3. Merge all servers from the found mcp.json files into one dict. "First wins": if two mcp.json files both +// define a server named "chrome", the FIRST one in `mcpJsonPaths` takes priority. Because Step 2 puts the user-level +// mcp.json first, the user-level entry wins over any workspace override +const collectServers = (mcpJsonPaths) => { + const serversByName = Object.create(null); + for (const mcpJsonPath of mcpJsonPaths) { + const mcpJson = readMcpJson(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; +}; + + +// Step 4. Tool name → server name. +// VS Code tool names look like: "mcp__" +// e.g. server "chrome-devtools-mcp" → tool "mcp_chrome-devtoo_new_page" +// We walk both strings side-by-side until they diverge; the server whose +// prefix matches the longest stretch of the tool name 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); + + // Count how many leading chars match. + let matchedLen = 0; + while (matchedLen < maxLen && sanitized[matchedLen] === toolSuffix[matchedLen]) matchedLen++; + if (matchedLen === 0) continue; + + // Right after the match the tool name must be "_" (separator) or end. + const fits = matchedLen === toolSuffix.length || toolSuffix[matchedLen] === "_"; + if (fits && matchedLen > bestLength) { + bestName = serverName; + bestLength = matchedLen; + } + } + return bestName; +}; + + +// Step 5. Check the server's launch command against POLICY. +// Returns null on a successful match. Otherwise returns a short string saying which part of the policy failed. +// We require: command=npx, "--yes", "@jfrog/agent-guard", and a "--registry " pair. +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`; + } + return null; +}; + + +// Step 6. Emit one audit-log line and exit. +// deny → write to stderr (VS Code shows the reason) and exit 2. +// allow → silent and 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); +}; + + +// The hook. +const main = async () => { + 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 ?? ""; + + // VS Code sends cwd in the payload. process.cwd() is the fallback for command-line testing + const cwd = request.cwd ?? process.cwd(); + + // Non-MCP tools (run_in_terminal, read_file, memory, …) 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)); + + // Server not in any mcp.json. Deny. + 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, + }); +}; + + +main().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/mcp-gate/bin/jfrog-setup-user.mjs b/mcp-gate/bin/jfrog-setup-user.mjs new file mode 100755 index 0000000..8ce9790 --- /dev/null +++ b/mcp-gate/bin/jfrog-setup-user.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// jfrog-setup-user — per-user setup. Idempotent. Runs at login + every 60s. +// +// Two modes: +// (default) apply one tick: write hook config + update settings.json. +// --clean strip our entry from settings.json (used by uninstall). +// "Idempotent" = if disk already matches the target, do nothing. +// "Tick" = one pass of the setup loop. + +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { dirname, join, resolve as resolvePath } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + HOOK_CONFIG_TILDE, + PRODUCT_NAME, + VSCODE_SETTINGS_PATH, + audit, + buildHookConfig, + stripJsonc, +} from "../lib/config.mjs"; + +// Constants +const IS_MAC= platform() === "darwin"; +const IS_WIN= platform() === "win32"; +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); // folder this script lives in +const HOOK_BIN = process.env.JFROG_MCP_GATE_HOOK_BIN ?? resolvePath(SCRIPT_DIR, "jfrog-mcp-gate.mjs"); +const USER_HOME = process.env.JFROG_MCP_GATE_HOME ?? homedir(); +const MCP_GATE_DIR = join(USER_HOME, ".jfrog", "mcp-gate"); +const HOOK_CONFIG = join(MCP_GATE_DIR, "vscode-hooks.json"); + +const SETTINGS_PATH = process.env.JFROG_MCP_GATE_HOME + ? (IS_MAC ? join(USER_HOME, "Library/Application Support/Code/User/settings.json") + : IS_WIN ? join(USER_HOME, "AppData/Roaming/Code/User/settings.json") + : join(USER_HOME, ".config/Code/User/settings.json")) + : VSCODE_SETTINGS_PATH; + +const SETTINGS_INDENT = 2; + +// Read settings.json. Returns {} if the file doesn't exist. +const readSettings = () => { + if (!existsSync(SETTINGS_PATH)) return {}; + try { return JSON.parse(stripJsonc(readFileSync(SETTINGS_PATH, "utf8"))) ?? {}; } + catch (err) { + process.stderr.write(`${PRODUCT_NAME}: cannot parse ${SETTINGS_PATH}: ${err.message}\nFix manually and rerun.\n`); + process.exit(1); + } +}; + + +// Atomic write: write to a sibling temp file, then rename it onto the +// target. Used for both vscode-hooks.json and settings.json. +const atomicWrite = (path, text) => { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, text, "utf8"); + renameSync(tmp, path); +}; + + +// Pure transforms on the parsed settings.json. +const reseededSettings = (current) => { + const next = { ...current }; + delete next["chat.hooks.enabled"]; // legacy stray key from earlier VS Code versions + const existing = next["chat.hookFilesLocations"]; + const locations = existing && typeof existing === "object" ? { ...existing } : {}; + locations[HOOK_CONFIG_TILDE] = true; + next["chat.hookFilesLocations"] = locations; + return next; +}; + +const cleanedSettings = (current) => { + const next = { ...current }; + delete next["chat.hooks.enabled"]; + const existing = next["chat.hookFilesLocations"]; + const locations = existing && typeof existing === "object" ? { ...existing } : {}; + delete locations[HOOK_CONFIG_TILDE]; + if (Object.keys(locations).length === 0) delete next["chat.hookFilesLocations"]; + else next["chat.hookFilesLocations"] = locations; + return next; +}; + + +// stringify the target, read the file, compare. If they match we skip the write. +const tick = () => { + const targetHookText= JSON.stringify(buildHookConfig(HOOK_BIN), null, 2) + "\n"; + const targetSettingsText= JSON.stringify(reseededSettings(readSettings()), null, SETTINGS_INDENT) + "\n"; + + const currentHookText= existsSync(HOOK_CONFIG) ? readFileSync(HOOK_CONFIG,"utf8") : null; + const currentSettingsText= existsSync(SETTINGS_PATH) ? readFileSync(SETTINGS_PATH, "utf8") : ""; + + const hookDrifted= currentHookText !== targetHookText; + const settingsDrifted= currentSettingsText !== targetSettingsText; + + if (!hookDrifted && !settingsDrifted) { + audit({ event_type: "setup_user_tick", changed: false }); + return; + } + + if (hookDrifted) { + atomicWrite(HOOK_CONFIG, targetHookText); + audit({ event_type: "reseed", target: HOOK_CONFIG, reason: currentHookText == null ? "created" : "updated" }); + } + if (settingsDrifted) { + atomicWrite(SETTINGS_PATH, targetSettingsText); + audit({ event_type: "reseed", target: SETTINGS_PATH, reason: "applied chat.hookFilesLocations" }); + } + + audit({ event_type: "setup_user_tick", changed: true }); +}; + + +// --clean — the reverse of a tick. +// Strip our chat.hookFilesLocations entry from settings.json. The hook-config directory itself is removed by the +// OS uninstaller, not here. +const clean = () => { + if (!existsSync(SETTINGS_PATH)) return; + const currentText = readFileSync(SETTINGS_PATH, "utf8"); + const nextText = JSON.stringify(cleanedSettings(readSettings()), null, SETTINGS_INDENT) + "\n"; + if (currentText !== nextText) atomicWrite(SETTINGS_PATH, nextText); +}; + + +// Entrypoint +if (process.argv[2] === "--clean") clean(); +else tick(); diff --git a/mcp-gate/com.jfrog.mcp-gate.mobileconfig b/mcp-gate/com.jfrog.mcp-gate.mobileconfig new file mode 100644 index 0000000..2c87a65 --- /dev/null +++ b/mcp-gate/com.jfrog.mcp-gate.mobileconfig @@ -0,0 +1,59 @@ + + + + + + PayloadContent + + + PayloadDisplayName + Visual Studio Code - JFrog MCP Gate + PayloadIdentifier + com.jfrog.mcp-gate.vscode.629FDCB6-2870-464A-9D0D-29BB69BAA49E + PayloadType + com.microsoft.VSCode + PayloadUUID + 629FDCB6-2870-464A-9D0D-29BB69BAA49E + PayloadVersion + 1 + ChatHooks + + + + PayloadDescription + Forces VS Code chat hooks ON so the JFrog MCP gate PreToolUse hook cannot be disabled by users. + PayloadDisplayName + JFrog MCP Gate + PayloadIdentifier + com.jfrog.mcp-gate + PayloadOrganization + JFrog Ltd. + PayloadRemovalDisallowed + + PayloadScope + System + PayloadType + Configuration + PayloadUUID + 6F3E5914-1350-4637-86B5-A8B3006035D9 + PayloadVersion + 1 + + diff --git a/mcp-gate/install.ps1 b/mcp-gate/install.ps1 new file mode 100644 index 0000000..6a836a4 --- /dev/null +++ b/mcp-gate/install.ps1 @@ -0,0 +1,141 @@ +# jfrog-mcp-gate installer (Windows). macOS / Linux: see install.sh. +# Production: iwr -useb https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/install.ps1 | iex +# (must run in an elevated PowerShell — "Run as Administrator") +# Local test: .\install.ps1 -Package .\dist\mcp-gate-.tgz +#Requires -RunAsAdministrator + +[CmdletBinding()] +param( + [string]$Package = "" +) + +$ErrorActionPreference = "Stop" + +# Settings — the upstream URL is baked in. To install from a local .tgz +# instead of Artifactory pass -Package . +$Url = "https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate" +$InstallRoot = Join-Path $env:ProgramFiles "JFrog\mcp-gate" +$LogDir = Join-Path $env:ProgramData "JFrog\Logs" +$AuditLog = Join-Path $LogDir "jfrog-mcp-gate.log" +$TaskName = "JFrogMcpUserSetup" + +# Preflight — node (for the hook + setup-user at runtime) and tar (for unpacking the .tgz). Windows 10+ ships tar.exe. +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + throw "install.ps1: 'node' not on PATH (need Node.js >= 20)." +} +if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { + throw "install.ps1: 'tar' not on PATH (Windows 10+ ships tar.exe; install BSD tar otherwise)." +} + +# Stage the payload in a temp dir we clean up at the end (try/finally below). +# Ends up with bin\, lib\, VERSION after the tar extracts. +$Stage = Join-Path $env:TEMP ("jfrog-mcp-gate-" + [guid]::NewGuid()) +New-Item -ItemType Directory -Path $Stage -Force | Out-Null + +try { + $payload = Join-Path $Stage "payload.tgz" + + if ($Package) { + if (-not (Test-Path $Package)) { throw "install.ps1: package not found: $Package" } + Write-Host "==> Installing from local package: $Package" + Copy-Item $Package $payload + } else { + # Resolve "latest" by fetching the LATEST file (one line of text, + # the version number). Then download mcp-gate-.tgz. + Write-Host "==> Resolving latest version from $Url/LATEST" + $Version = (Invoke-WebRequest -UseBasicParsing -Uri "$Url/LATEST").Content.Trim() + if (-not $Version) { throw "install.ps1: could not resolve version." } + Write-Host "==> Installing jfrog-mcp-gate $Version from $Url" + Invoke-WebRequest -UseBasicParsing -Uri "$Url/v$Version/mcp-gate-$Version.tgz" -OutFile $payload + } + + tar -xzf $payload -C $Stage + Remove-Item $payload + + foreach ($p in @("bin\jfrog-mcp-gate.mjs", "bin\jfrog-setup-user.mjs", "lib\config.mjs", "VERSION")) { + if (-not (Test-Path (Join-Path $Stage $p))) { throw "install.ps1: payload missing $p" } + } + + # Lay down the install root — Program Files inherits ACLs that give + # Administrators write / Users read+execute, exactly what we want. + Write-Host "==> Installing into $InstallRoot" + if (Test-Path $InstallRoot) { Remove-Item -Recurse -Force $InstallRoot } + New-Item -ItemType Directory -Path (Join-Path $InstallRoot "bin") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $InstallRoot "lib") -Force | Out-Null + + Copy-Item (Join-Path $Stage "bin\jfrog-mcp-gate.mjs") (Join-Path $InstallRoot "bin\jfrog-mcp-gate.mjs") + Copy-Item (Join-Path $Stage "bin\jfrog-setup-user.mjs") (Join-Path $InstallRoot "bin\jfrog-setup-user.mjs") + Copy-Item (Join-Path $Stage "lib\config.mjs") (Join-Path $InstallRoot "lib\config.mjs") + Copy-Item (Join-Path $Stage "VERSION") (Join-Path $InstallRoot "VERSION") + + # Audit log — grant the BUILTIN\Users group Modify so the user-mode + # hook + setup-user can append. ProgramData defaults are tighter. + New-Item -ItemType Directory -Path $LogDir -Force | Out-Null + if (-not (Test-Path $AuditLog)) { New-Item -ItemType File -Path $AuditLog -Force | Out-Null } + $acl = Get-Acl $AuditLog + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + "BUILTIN\Users", "Modify", "Allow") + $acl.AddAccessRule($rule) + Set-Acl $AuditLog $acl + + # Scheduled Task — Windows' equivalent of LaunchAgent/systemd-timer. + # Runs at logon + every 1 min as the interactive user (so setup-user + # can write under %USERPROFILE%\.jfrog\ and edit their settings.json). + $setupBin = Join-Path $InstallRoot "bin\jfrog-setup-user.mjs" + $action = New-ScheduledTaskAction -Execute "node.exe" -Argument "`"$setupBin`"" + $atLogon = New-ScheduledTaskTrigger -AtLogOn + + # Windows doesn't have a "every-N-minutes-forever" trigger directly. + # Workaround: a one-shot trigger starting in 1 min, with a 1-min + # repetition interval and a very long repetition duration (~1 year). + $repeat = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` + -RepetitionInterval (New-TimeSpan -Minutes 1) ` + -RepetitionDuration (New-TimeSpan -Hours 9999) + + # Task settings — survive battery transitions so the heal-on-tick + # keeps working on a laptop. + $settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -StartWhenAvailable + + # Run the task as whoever is interactively logged in (not SYSTEM, not + # a specific account). S-1-5-32-545 = the built-in "Users" group SID. + $principal = New-ScheduledTaskPrincipal -GroupId "S-1-5-32-545" + + # Unregister-then-register replaces any older task with the same name, + # so reinstalls don't accumulate stale schedules. + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + Register-ScheduledTask ` + -TaskName $TaskName ` + -Description "JFrog mcp-gate per-user setup (logon + every 60s)" ` + -Action $action ` + -Trigger @($atLogon, $repeat) ` + -Settings $settings ` + -Principal $principal | Out-Null + + # Kick the task once so the user doesn't have to wait for the timer. + Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + +} finally { + Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue +} + +# Done — print a "what next" hint + +$installed = (Get-Content (Join-Path $InstallRoot "VERSION") -Raw).Trim() +Write-Host "" +Write-Host "==> Installed jfrog-mcp-gate $installed (windows)." +Write-Host "" +Write-Host "Per-user state appears on the next task tick (<=60s):" +Write-Host " %USERPROFILE%\.jfrog\mcp-gate\vscode-hooks.json" +Write-Host " chat.hookFilesLocations entry in VS Code user settings" +Write-Host "" +Write-Host "Next:" +Write-Host " 1. Push ChatHooks=1 via Group Policy" +Write-Host " HKLM\Software\Policies\Microsoft\VSCode\ChatHooks (REG_DWORD, 1)" +Write-Host " 2. Restart VS Code." +Write-Host " 3. Get-Content -Tail 0 -Wait $AuditLog" +Write-Host "" +Write-Host "Uninstall:" +Write-Host " iwr -useb $Url/uninstall.ps1 | iex" diff --git a/mcp-gate/install.sh b/mcp-gate/install.sh new file mode 100755 index 0000000..dab0792 --- /dev/null +++ b/mcp-gate/install.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# jfrog-mcp-gate installer (macOS + Linux). +# Production: curl -sSfL https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/install.sh | sudo bash +# Local test: sudo ./install.sh --package dist/mcp-gate-.tgz + +set -euo pipefail + +# Settings — paths and the upstream URL are baked in. To install from a +# local .tgz instead of Artifactory pass --package . +URL="https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate" +INSTALL_ROOT="/usr/local/jfrog/mcp-gate" +AUDIT_LOG="/var/log/jfrog-mcp-gate.log" + +# Parse CLI args +LOCAL_PACKAGE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --package) LOCAL_PACKAGE="$2"; shift 2 ;; + -h|--help) sed -n '2,5p' "$0"; exit 0 ;; + *) echo "install.sh: unknown arg '$1' (try --help)" >&2; exit 1 ;; + esac +done + +# OS dispatch — macOS uses LaunchAgents (in /Library/LaunchAgents) and the wheel group; Linux uses systemd --user +# units and group root. +case "$(uname -s)" in + Darwin) PLATFORM=macos; GROUP=wheel; SETUP_LOG_DIR="/Library/Logs/jfrog-mcp-gate" ;; + Linux) PLATFORM=linux; GROUP=root; SETUP_LOG_DIR="" ;; # Linux logs to journald + *) + # Windows users need install.ps1 — they typically only hit this + # path if they ran the script via Git Bash or WSL by mistake. + echo "install.sh: unsupported OS '$(uname -s)' (Windows uses install.ps1)." >&2 + exit 1 + ;; +esac + +# Preflight — must run as root because we write to /usr/local + /var/log. +# node + tar are needed at install time (and node at every hook call). +[[ $EUID -eq 0 ]] || { echo "install.sh: must run as root (use 'sudo')." >&2; exit 1; } +command -v node >/dev/null 2>&1 || { echo "install.sh: 'node' not in PATH (need Node.js >= 20)." >&2; exit 1; } +command -v tar >/dev/null 2>&1 || { echo "install.sh: 'tar' not in PATH." >&2; exit 1; } + +# Stage the payload in a temp dir we clean up on exit. The dir ends up +# with bin/, lib/, VERSION after the tar extracts. +STAGE=$(mktemp -d -t jfrog-mcp-gate.XXXXXX) +trap 'rm -rf "${STAGE}"' EXIT + +if [[ -n "${LOCAL_PACKAGE}" ]]; then + [[ -f "${LOCAL_PACKAGE}" ]] || { echo "install.sh: package not found: ${LOCAL_PACKAGE}" >&2; exit 1; } + echo "==> Installing from local package: ${LOCAL_PACKAGE}" + cp "${LOCAL_PACKAGE}" "${STAGE}/payload.tgz" +else + # Resolve "latest" by fetching the LATEST file (one line of text, the + # version number). Then download mcp-gate-.tgz from that subdir. + echo "==> Resolving latest version from ${URL}/LATEST" + LATEST_VERSION="$(curl -sSfL "${URL}/LATEST" | tr -d '[:space:]')" + [[ -n "${LATEST_VERSION}" ]] || { echo "install.sh: could not resolve version." >&2; exit 1; } + echo "==> Installing jfrog-mcp-gate ${LATEST_VERSION} from ${URL}" + curl -sSfL -o "${STAGE}/payload.tgz" "${URL}/v${LATEST_VERSION}/mcp-gate-${LATEST_VERSION}.tgz" +fi + +tar -xzf "${STAGE}/payload.tgz" -C "${STAGE}" +rm -f "${STAGE}/payload.tgz" + +# Sanity-check the payload before we touch the install root. +[[ -x "${STAGE}/bin/jfrog-mcp-gate.mjs" ]] || { echo "install.sh: payload missing bin/jfrog-mcp-gate.mjs" >&2; exit 1; } +[[ -x "${STAGE}/bin/jfrog-setup-user.mjs" ]] || { echo "install.sh: payload missing bin/jfrog-setup-user.mjs" >&2; exit 1; } +[[ -f "${STAGE}/lib/config.mjs" ]] || { echo "install.sh: payload missing lib/config.mjs" >&2; exit 1; } +[[ -f "${STAGE}/VERSION" ]] || { echo "install.sh: payload missing VERSION" >&2; exit 1; } + +# Lay down the install root — root-owned + 0755 so users can read/run +# but cannot modify without sudo. We blow the dir away first so reinstalls +# don't accumulate stale files. +echo "==> Installing into ${INSTALL_ROOT}" +rm -rf "${INSTALL_ROOT}" +mkdir -p "${INSTALL_ROOT}/bin" "${INSTALL_ROOT}/lib" + +install -o root -g "${GROUP}" -m 0755 "${STAGE}/bin/jfrog-mcp-gate.mjs" "${INSTALL_ROOT}/bin/jfrog-mcp-gate.mjs" +install -o root -g "${GROUP}" -m 0755 "${STAGE}/bin/jfrog-setup-user.mjs" "${INSTALL_ROOT}/bin/jfrog-setup-user.mjs" +install -o root -g "${GROUP}" -m 0644 "${STAGE}/lib/config.mjs" "${INSTALL_ROOT}/lib/config.mjs" +install -o root -g "${GROUP}" -m 0644 "${STAGE}/VERSION" "${INSTALL_ROOT}/VERSION" + +# Audit log — `touch` creates the file if missing (and is a no-op when +# it exists, so reinstalls preserve existing audit lines). chmod 0666 +# lets the user-mode hook + setup-user both append. +touch "${AUDIT_LOG}" +chown "root:${GROUP}" "${AUDIT_LOG}" +chmod 0666 "${AUDIT_LOG}" + +# Per-tick log directory (macOS only — Linux logs to journald). +# 0777 because we don't know yet which user the LaunchAgent will run as. +if [[ -n "${SETUP_LOG_DIR}" ]]; then + mkdir -p "${SETUP_LOG_DIR}" + chmod 0777 "${SETUP_LOG_DIR}" +fi + +# Platform-specific service registration +if [[ "${PLATFORM}" == "macos" ]]; then + PLIST_DEST="/Library/LaunchAgents/com.jfrog.mcp-user-setup.plist" + echo "==> Installing LaunchAgent at ${PLIST_DEST}" + + # LaunchAgent plist — describes a per-user background service to launchd. + # RunAtLoad = run once when the agent loads (i.e. at every login) + # StartInterval = re-run every N seconds (here: 60s, our heal-on-tick) + # ProgramArgs = the command line to execute + # PATH override = launchd's default PATH doesn't include /opt/homebrew + # /usr/local/bin where most users have `node` + cat > "${PLIST_DEST}" < + + + + Label + com.jfrog.mcp-user-setup + ProgramArguments + + /usr/bin/env + node + ${INSTALL_ROOT}/bin/jfrog-setup-user.mjs + + RunAtLoad + + StartInterval + 60 + StandardOutPath + ${SETUP_LOG_DIR}/setup.stdout.log + StandardErrorPath + ${SETUP_LOG_DIR}/setup.stderr.log + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + + +EOF + chown root:wheel "${PLIST_DEST}" + chmod 0644 "${PLIST_DEST}" + + # Kick the LaunchAgent into the currently-logged-in GUI session. + # bootout = unload any older instance with the same Label + # bootstrap = load the (possibly new) plist into the user session + # kickstart = force one immediate tick (-k = re-run even if it just ran) + # /dev/console belongs to whoever owns the active GUI session. + LOGGED_IN_UID=$(stat -f%u /dev/console 2>/dev/null || echo "") + LOGGED_IN_USER=$(stat -f%Su /dev/console 2>/dev/null || echo "") + if [[ -n "${LOGGED_IN_UID}" && "${LOGGED_IN_UID}" != "0" ]]; then + echo "==> Bootstrapping LaunchAgent into uid=${LOGGED_IN_UID} (${LOGGED_IN_USER})" + launchctl bootout "gui/${LOGGED_IN_UID}/com.jfrog.mcp-user-setup" 2>/dev/null || true + launchctl bootstrap "gui/${LOGGED_IN_UID}" "${PLIST_DEST}" + launchctl kickstart -k "gui/${LOGGED_IN_UID}/com.jfrog.mcp-user-setup" + fi + +else + # Linux: systemd --user service + timer. They live in /etc/systemd/user/ + # so every user's `systemctl --user` session picks them up automatically. + # Logs go to journald — view with: journalctl --user -u jfrog-mcp-user-setup.service + SYSTEMD_DIR="/etc/systemd/user" + SERVICE_UNIT="${SYSTEMD_DIR}/jfrog-mcp-user-setup.service" + TIMER_UNIT="${SYSTEMD_DIR}/jfrog-mcp-user-setup.timer" + + echo "==> Installing systemd --user units in ${SYSTEMD_DIR}" + mkdir -p "${SYSTEMD_DIR}" + + # The .service runs once on each timer fire. + cat > "${SERVICE_UNIT}" < "${TIMER_UNIT}" </dev/null 2>&1 || true + + # Best-effort: kick the timer in the currently-logged-in user's session + # so they don't have to log out/in. systemd --user needs that user's + # XDG_RUNTIME_DIR which sudo doesn't carry — so we point at it explicitly. + LOGGED_IN_USER=$(logname 2>/dev/null || echo "") + LOGGED_IN_UID=$(id -u "${LOGGED_IN_USER}" 2>/dev/null || echo "") + if [[ -n "${LOGGED_IN_UID}" && "${LOGGED_IN_UID}" != "0" ]]; then + echo "==> Starting timer in uid=${LOGGED_IN_UID} (${LOGGED_IN_USER}) session" + sudo -u "${LOGGED_IN_USER}" \ + XDG_RUNTIME_DIR="/run/user/${LOGGED_IN_UID}" \ + systemctl --user daemon-reload >/dev/null 2>&1 || true + sudo -u "${LOGGED_IN_USER}" \ + XDG_RUNTIME_DIR="/run/user/${LOGGED_IN_UID}" \ + systemctl --user enable --now jfrog-mcp-user-setup.timer >/dev/null 2>&1 || true + fi +fi + +# Done — print a "what next" hint + +INSTALLED_VERSION="$(cat "${INSTALL_ROOT}/VERSION")" +cat < Installed jfrog-mcp-gate ${INSTALLED_VERSION} (${PLATFORM}). + +Per-user state will appear on the next service tick (<=60s): + ~/.jfrog/mcp-gate/vscode-hooks.json + chat.hookFilesLocations entry in VS Code user settings + +Next: + 1. Push the VS Code enterprise ChatHooks=true policy via your MDM. + macOS: com.jfrog.mcp-gate.mobileconfig + Linux: write /etc/vscode/policy.json with {"ChatHooks": true} + 2. Restart VS Code. + 3. tail -f ${AUDIT_LOG} + +Uninstall: + sudo bash -c "\$(curl -sSfL ${URL}/uninstall.sh)" +EOF diff --git a/mcp-gate/lib/config.mjs b/mcp-gate/lib/config.mjs new file mode 100644 index 0000000..4b2c27c --- /dev/null +++ b/mcp-gate/lib/config.mjs @@ -0,0 +1,72 @@ +import { appendFileSync, mkdirSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { dirname, join } from "node:path"; + +// Product identity +export const PRODUCT_NAME = "jfrog-mcp-gate"; + + +// Policy — the launch command we require. Anything else is DENIED. +// Example mcp.json entry that PASSES this policy: +// "command": "npx", +// "args": ["--yes", "--registry", "", "@jfrog/agent-guard", "chrome-devtools-mcp@latest"] +export const POLICY = { + command: "npx", + required_args: ["--yes", "@jfrog/agent-guard"], + registry_arg: "--registry", +}; + + +// Where the JSON-per-line audit log lives. +export const AUDIT_LOG_PATH = platform() === "win32" + ? join(process.env.ProgramData ?? "C:\\ProgramData", "JFrog\\Logs\\jfrog-mcp-gate.log") + : "/var/log/jfrog-mcp-gate.log"; + +// VS Code's user-level config folder. +export const VSCODE_USER_DIR = (() => { + const home = homedir(); + if (platform() === "darwin") return join(home, "Library/Application Support/Code/User"); + if (platform() === "win32") return join(process.env.APPDATA ?? home, "Code/User"); + return join(home, ".config/Code/User"); +})(); + +export const VSCODE_SETTINGS_PATH = join(VSCODE_USER_DIR, "settings.json"); + +// Where VS Code looks for our hook config. +export const HOOK_CONFIG_TILDE = "~/.jfrog/mcp-gate/vscode-hooks.json"; + +// Hook-config payload — the JSON that setup-user writes to ~/.jfrog/mcp-gate/vscode-hooks.json. VS Code reads this file +// and spawns `command` for every PreToolUse event. +export const buildHookConfig = (hookBinAbsPath) => ({ + version: 1, + hooks: { + PreToolUse: [{ type: "command", command: hookBinAbsPath }], + }, +}); + + +// JSONC helpers +// VS Code's settings.json + mcp.json allow comments and trailing commas that plain JSON.parse rejects. +// stripJsonc removes them. +export const stripJsonc = (s) => + s + .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, comment) => (comment ? "" : m)) + .replace(/,(\s*[}\]])/g, "$1"); + +export const parseJsonc = (s) => JSON.parse(stripJsonc(s)); + + +// Audit logger — append one JSON entry per line. Never throws. +// Example deny line: +// {"ts":"2026-...","product":"jfrog-mcp-gate","event_type":"decision", +// "tool_name":"mcp_x_y","server":"","decision":"deny", +// "reason":"server not found in mcp.json"} +export 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 */ } +}; diff --git a/mcp-gate/poc/release.sh b/mcp-gate/poc/release.sh new file mode 100755 index 0000000..74e0997 --- /dev/null +++ b/mcp-gate/poc/release.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Engineer-local release fallback. Canonical release is the GH workflow; +# this exists so we can ship updates before CI infra onboards the repo. +# Usage: ./poc/release.sh [--dry-run] +# Env: JFROG_MCP_GATE_REPO (default: jfrog-cli-plugins/jfrog-mcp-gate) +# Needs: jf (JFrog CLI) logged in, tar. + +set -euo pipefail + +# Settings +DRY_RUN=0 +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 + +MCP_GATE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # the mcp-gate/ folder +VERSION="$(cat "${MCP_GATE_ROOT}/VERSION")" +TGZ="mcp-gate-${VERSION}.tgz" +REPO="${JFROG_MCP_GATE_REPO:-jfrog-cli-plugins/jfrog-mcp-gate}" +TOP_LEVEL_FILES=(install.sh uninstall.sh install.ps1 uninstall.ps1 com.jfrog.mcp-gate.mobileconfig) + +# Preflight (need VERSION + tar; need jf if not dry-run) + +[[ -n "${VERSION}" ]] || { echo "release.sh: VERSION is empty." >&2; exit 1; } +command -v tar >/dev/null 2>&1 || { echo "release.sh: tar 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 + +# Build the install package (.tgz) + LATEST file +DIST="${MCP_GATE_ROOT}/dist" +mkdir -p "${DIST}" +rm -f "${DIST}/${TGZ}" "${DIST}/LATEST" + +echo "==> Packaging ${TGZ} (version=${VERSION})" +tar -C "${MCP_GATE_ROOT}" -czf "${DIST}/${TGZ}" bin lib VERSION +echo "${VERSION}" > "${DIST}/LATEST" + +echo " -> ${DIST}/${TGZ}" +echo " -> ${DIST}/LATEST" + +# Upload to Artifactory (or print what would have been uploaded) +if [[ ${DRY_RUN} -eq 1 ]]; then + echo + echo "==> Dry run, skipping upload." + echo " Would upload to ${REPO}:" + echo " v${VERSION}/${TGZ}" + echo " LATEST" + for f in "${TOP_LEVEL_FILES[@]}"; do echo " ${f}"; done + exit 0 +fi + +echo +echo "==> Uploading versioned package -> ${REPO}/v${VERSION}/" +jf rt upload "${DIST}/${TGZ}" "${REPO}/v${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 "${MCP_GATE_ROOT}/${f}" "${REPO}/${f}" +done + +cat < Release ${VERSION} published to ${REPO}. + IT command: + curl -sSfL https://\${ARTIFACTORY_HOST}/artifactory/${REPO}/install.sh | sudo bash +EOF diff --git a/mcp-gate/uninstall.ps1 b/mcp-gate/uninstall.ps1 new file mode 100644 index 0000000..50133fe --- /dev/null +++ b/mcp-gate/uninstall.ps1 @@ -0,0 +1,81 @@ +# jfrog-mcp-gate uninstaller (Windows). macOS / Linux users: uninstall.sh. +# Removes everything install.ps1 wrote plus the per-user state for the +# currently-logged-in user. The audit log is preserved for forensics. +#Requires -RunAsAdministrator + +$ErrorActionPreference = "Stop" + +$InstallRoot = Join-Path $env:ProgramFiles "JFrog\mcp-gate" +$AuditLog = Join-Path $env:ProgramData "JFrog\Logs\jfrog-mcp-gate.log" +$TaskName = "JFrogMcpUserSetup" + +# Stop the Scheduled Task. We do this BEFORE removing files so the task +# can't fire one last time mid-uninstall and recreate state. +if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { + Write-Host "==> Unregistering Scheduled Task $TaskName" + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false +} + +# Per-user state cleanup (BEFORE deleting the install root, because we +# call setup-user.mjs --clean from it). +# +# This script runs as Administrator but the user data we want to delete +# lives in another user's profile (Documents, AppData, .jfrog\). So: +# 1. Find who's interactively logged in (Win32_ComputerSystem.UserName +# gives "DOMAIN\username"). +# 2. Find that user's profile folder via Win32_UserProfile.LocalPath. +# 3. Set JFROG_MCP_GATE_HOME so setup-user.mjs writes to THEIR home +# instead of the Administrator's. +$activeUser = (Get-CimInstance -ClassName Win32_ComputerSystem).UserName +if ($activeUser) { + $userOnly = $activeUser.Split("\")[-1] # "DOMAIN\foo" -> "foo" + $userHome = (Get-CimInstance -ClassName Win32_UserProfile | + Where-Object { $_.LocalPath -like "*\$userOnly" } | + Select-Object -First 1).LocalPath + + if ($userHome) { + $McpGateDir = Join-Path $userHome ".jfrog\mcp-gate" + $HookConfig = Join-Path $McpGateDir "vscode-hooks.json" + $setupBin = Join-Path $InstallRoot "bin\jfrog-setup-user.mjs" + + if (Test-Path $setupBin) { + Write-Host "==> Stripping chat.hookFilesLocations entry for $userOnly" + # Temporarily set JFROG_MCP_GATE_HOME so setup-user.mjs + # operates on the active user's settings.json, not ours. + $env:JFROG_MCP_GATE_HOME = $userHome + try { & node $setupBin --clean } catch { } + Remove-Item Env:JFROG_MCP_GATE_HOME -ErrorAction SilentlyContinue + } + + if (Test-Path $HookConfig) { + Write-Host "==> Removing $HookConfig" + Remove-Item $HookConfig -Force + } + # Remove the parent dir only if it became empty (-Force without + # -Recurse on a folder fails if not empty — that's the behavior + # we want). + if (Test-Path $McpGateDir) { + try { Remove-Item $McpGateDir -Force } catch { } + } + } +} + +# Install root + (if empty) the JFrog parent folder. +if (Test-Path $InstallRoot) { + Write-Host "==> Removing $InstallRoot" + Remove-Item -Recurse -Force $InstallRoot +} +$parent = Split-Path $InstallRoot -Parent +if ((Test-Path $parent) -and -not (Get-ChildItem $parent -ErrorAction SilentlyContinue)) { + Remove-Item $parent +} + +# Done — print a "what's preserved" hint +Write-Host "" +Write-Host "==> Uninstall complete." +Write-Host "" +Write-Host "Preserved for forensics:" +Write-Host " $AuditLog" +Write-Host "" +Write-Host "To remove the ChatHooks=1 enterprise policy:" +Write-Host " reg delete HKLM\Software\Policies\Microsoft\VSCode /v ChatHooks /f" diff --git a/mcp-gate/uninstall.sh b/mcp-gate/uninstall.sh new file mode 100755 index 0000000..4be42f5 --- /dev/null +++ b/mcp-gate/uninstall.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# jfrog-mcp-gate uninstaller (macOS + Linux). Windows users: uninstall.ps1. +# Removes everything install.sh wrote plus the per-user state for the +# currently-logged-in user. The audit log is preserved for forensics. +set -euo pipefail + +INSTALL_ROOT="/usr/local/jfrog/mcp-gate" +AUDIT_LOG="/var/log/jfrog-mcp-gate.log" + +# OS dispatch + active-user lookup. +# We capture both the username and the uid because launchctl/systemctl +# both need the uid to address the right user session. +# macOS: stat /dev/console — that device is owned by whoever owns the +# active GUI session. +# Linux: logname — the name of the user who started the login session +# we inherited from sudo. +case "$(uname -s)" in + Darwin) + PLATFORM=macos + LOGGED_IN_USER=$(stat -f%Su /dev/console 2>/dev/null || echo "") + LOGGED_IN_UID=$(stat -f%u /dev/console 2>/dev/null || echo "") + PLIST_DEST="/Library/LaunchAgents/com.jfrog.mcp-user-setup.plist" + SETUP_LOG_HINT="/Library/Logs/jfrog-mcp-gate/setup.{stdout,stderr}.log" + ;; + Linux) + PLATFORM=linux + LOGGED_IN_USER=$(logname 2>/dev/null || echo "") + LOGGED_IN_UID=$(id -u "${LOGGED_IN_USER}" 2>/dev/null || echo "") + SYSTEMD_DIR="/etc/systemd/user" + SETUP_LOG_HINT="journalctl --user -u jfrog-mcp-user-setup.service (until cleared)" + ;; + *) + echo "uninstall.sh: unsupported OS '$(uname -s)' (Windows uses uninstall.ps1)." >&2 + exit 1 + ;; +esac + +[[ $EUID -eq 0 ]] || { echo "uninstall.sh: must run as root." >&2; exit 1; } + +# Stop the per-user service. We do this BEFORE removing files so the +# scheduler can't fire one last tick mid-uninstall and recreate state. +if [[ "${PLATFORM}" == "macos" ]]; then + # bootout = the opposite of bootstrap — unload the LaunchAgent from + # the active GUI session. + if [[ -n "${LOGGED_IN_UID}" && "${LOGGED_IN_UID}" != "0" ]]; then + echo "==> Booting out LaunchAgent from uid=${LOGGED_IN_UID}" + launchctl bootout "gui/${LOGGED_IN_UID}/com.jfrog.mcp-user-setup" 2>/dev/null || true + fi +else + # disable --now = stop the timer right now AND unenable it for future + # logins. Reach across the sudo boundary by setting XDG_RUNTIME_DIR. + if [[ -n "${LOGGED_IN_UID}" && "${LOGGED_IN_UID}" != "0" ]]; then + echo "==> Stopping timer in uid=${LOGGED_IN_UID} (${LOGGED_IN_USER}) session" + sudo -u "${LOGGED_IN_USER}" \ + XDG_RUNTIME_DIR="/run/user/${LOGGED_IN_UID}" \ + systemctl --user disable --now jfrog-mcp-user-setup.timer >/dev/null 2>&1 || true + fi + # Also remove the system-wide --global enablement. + systemctl --global disable jfrog-mcp-user-setup.timer >/dev/null 2>&1 || true +fi + +# Per-user state cleanup (BEFORE deleting the install root, because we +# call setup-user.mjs --clean from it). We delegate settings.json +# cleanup to that script so the JSONC logic stays in one place. Runs +# AS the active user so file ownership stays correct. +if [[ -n "${LOGGED_IN_USER}" && "${LOGGED_IN_USER}" != "root" ]]; then + # eval echo expands ~user into the user's $HOME on all shells. + USER_HOME=$(eval echo "~${LOGGED_IN_USER}") + MCP_GATE_DIR="${USER_HOME}/.jfrog/mcp-gate" + HOOK_CONFIG="${MCP_GATE_DIR}/vscode-hooks.json" + SETUP_USER="${INSTALL_ROOT}/bin/jfrog-setup-user.mjs" + + if [[ -x "${SETUP_USER}" ]]; then + echo "==> Stripping chat.hookFilesLocations entry for ${LOGGED_IN_USER}" + sudo -u "${LOGGED_IN_USER}" node "${SETUP_USER}" --clean || true + fi + + [[ -f "${HOOK_CONFIG}" ]] && { echo "==> Removing ${HOOK_CONFIG}"; rm -f "${HOOK_CONFIG}"; } + # rmdir only succeeds if the dir is empty — exactly what we want here. + [[ -d "${MCP_GATE_DIR}" ]] && rmdir "${MCP_GATE_DIR}" 2>/dev/null || true +fi + +# Service files (plist on macOS / unit files on Linux) + install root. +if [[ "${PLATFORM}" == "macos" ]]; then + [[ -f "${PLIST_DEST}" ]] && { echo "==> Removing ${PLIST_DEST}"; rm -f "${PLIST_DEST}"; } +else + for unit in jfrog-mcp-user-setup.timer jfrog-mcp-user-setup.service; do + [[ -f "${SYSTEMD_DIR}/${unit}" ]] && { echo "==> Removing ${SYSTEMD_DIR}/${unit}"; rm -f "${SYSTEMD_DIR}/${unit}"; } + done +fi + +if [[ -d "${INSTALL_ROOT}" ]]; then + echo "==> Removing ${INSTALL_ROOT}" + rm -rf "${INSTALL_ROOT}" +fi +# Clean up the /usr/local/jfrog/ parent if nothing else is in it. +[[ -d "/usr/local/jfrog" && -z "$(ls -A /usr/local/jfrog)" ]] && rmdir /usr/local/jfrog + +# Done — print a "what's preserved" hint +cat < Uninstall complete. + +Preserved for forensics: + ${AUDIT_LOG} + ${SETUP_LOG_HINT} + +To remove the ChatHooks=true enterprise policy: + macOS: sudo profiles remove -identifier com.jfrog.mcp-gate + Linux: remove /etc/vscode/policy.json +EOF From 9b9082f97dbe7d0497b99b49b5ae86e68964ada7 Mon Sep 17 00:00:00 2001 From: yanivt Date: Wed, 27 May 2026 11:50:32 +0300 Subject: [PATCH 2/7] vscode Bypass --- .github/workflows/agent-guard-hook-ci.yml | 278 +++++++++++++ .github/workflows/merge-mcp-gate-dev.yml | 90 ----- .github/workflows/release-mcp-gate.yml | 231 ----------- agent-guard-hook/.jfrog-distribution.yml | 25 ++ agent-guard-hook/README.md | 139 +++++++ agent-guard-hook/agent-guard-hook.mjs | 370 ++++++++++++++++++ .../com.jfrog.agent-guard-hook.mobileconfig | 47 +++ agent-guard-hook/install.mjs | 202 ++++++++++ agent-guard-hook/poc/release.sh | 102 +++++ mcp-gate/.jfrog-distribution.yml | 35 -- mcp-gate/README.md | 352 ----------------- mcp-gate/VERSION | 1 - mcp-gate/bin/jfrog-mcp-gate.mjs | 212 ---------- mcp-gate/bin/jfrog-setup-user.mjs | 127 ------ mcp-gate/com.jfrog.mcp-gate.mobileconfig | 59 --- mcp-gate/install.ps1 | 141 ------- mcp-gate/install.sh | 230 ----------- mcp-gate/lib/config.mjs | 72 ---- mcp-gate/poc/release.sh | 67 ---- mcp-gate/uninstall.ps1 | 81 ---- mcp-gate/uninstall.sh | 111 ------ 21 files changed, 1163 insertions(+), 1809 deletions(-) create mode 100644 .github/workflows/agent-guard-hook-ci.yml delete mode 100644 .github/workflows/merge-mcp-gate-dev.yml delete mode 100644 .github/workflows/release-mcp-gate.yml create mode 100644 agent-guard-hook/.jfrog-distribution.yml create mode 100644 agent-guard-hook/README.md create mode 100644 agent-guard-hook/agent-guard-hook.mjs create mode 100644 agent-guard-hook/com.jfrog.agent-guard-hook.mobileconfig create mode 100644 agent-guard-hook/install.mjs create mode 100755 agent-guard-hook/poc/release.sh delete mode 100644 mcp-gate/.jfrog-distribution.yml delete mode 100644 mcp-gate/README.md delete mode 100644 mcp-gate/VERSION delete mode 100755 mcp-gate/bin/jfrog-mcp-gate.mjs delete mode 100755 mcp-gate/bin/jfrog-setup-user.mjs delete mode 100644 mcp-gate/com.jfrog.mcp-gate.mobileconfig delete mode 100644 mcp-gate/install.ps1 delete mode 100755 mcp-gate/install.sh delete mode 100644 mcp-gate/lib/config.mjs delete mode 100755 mcp-gate/poc/release.sh delete mode 100644 mcp-gate/uninstall.ps1 delete mode 100755 mcp-gate/uninstall.sh diff --git a/.github/workflows/agent-guard-hook-ci.yml b/.github/workflows/agent-guard-hook-ci.yml new file mode 100644 index 0000000..caf7fdd --- /dev/null +++ b/.github/workflows/agent-guard-hook-ci.yml @@ -0,0 +1,278 @@ +# agent-guard-hook CI Workflow +# One workflow for dev + release. +# - push to any branch → auto-detected: master/main = release, others = dev +# - pull_request targeting main → always a dev build against the PR's source +# branch. Exercises pre-build → build → post-build +# end-to-end so a merge can't silently break CI. +# distribution + promote-latest are skipped +# (release-only gate). +# - workflow_dispatch → respects the `build-type` input + + +name: agent-guard-hook CI + +on: + push: + paths: + - "agent-guard-hook/**" + - ".github/workflows/agent-guard-hook-ci.yml" + pull_request: + branches: [master, main] + paths: + - "agent-guard-hook/**" + - ".github/workflows/agent-guard-hook-ci.yml" + workflow_dispatch: + inputs: + build-type: + description: 'Override build type (auto=detect from branch, milestone=create milestone build from any branch)' + required: false + type: choice + options: + - auto + - dev + - release + - milestone + default: 'auto' + +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": "coding-agents-generic-dev-master-local" // or release repo + # } + # } + # } + pre-build: + name: Pre-Build + runs-on: devf-dind-amd-scale-set + 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 + build-type: ${{ inputs.build-type || 'auto' }} # auto → master/main = release, others = dev + create-release-candidate-tag: 'true' # push an RC git tag like agent-guard-hook/v0.1.1-rc1 + # 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 }} + # creates the Artifactory webhook that auto-provisions dev repos for new branches + jfrog-webhook-creds: ${{ secrets.JFDEV_WEBHOOK_CREDS }} + generic: 'true' # Single-file ".mjs" packaged as a generic .tgz. + + # Phase 5(a) — Custom build: 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: devf-dind-amd-scale-set + 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. + - name: Upload artifacts to Artifactory + 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 + 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 / milestone 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] + runs-on: devf-dind-amd-scale-set + 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 (milestone has distribute_to_edges=false). + # 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: devf-dind-amd-scale-set + 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: devf-dind-amd-scale-set + 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/.github/workflows/merge-mcp-gate-dev.yml b/.github/workflows/merge-mcp-gate-dev.yml deleted file mode 100644 index 147790e..0000000 --- a/.github/workflows/merge-mcp-gate-dev.yml +++ /dev/null @@ -1,90 +0,0 @@ -# Dev publish on every merge to main. Builds a pre-release install package -# `mcp-gate--dev..tgz` (run_number is GitHub's -# monotonic counter per workflow, e.g. 42 = the 42nd run) and uploads -# it to entplus dev master generic for internal testing. -# -# Engineers install a dev package by downloading the .tgz directly from -# entplus and passing --package: -# curl -sSfLO https://entplus.jfrog.io/artifactory//jfrog-mcp-gate/v0.1.0-dev.42/mcp-gate-0.1.0-dev.42.tgz -# sudo ./install.sh --package ./mcp-gate-0.1.0-dev.42.tgz -# -# Production releases go through release-mcp-gate.yml (manual trigger). - -name: Dev publish mcp-gate - -on: - push: - branches: - - main - paths: - - "mcp-gate/**" - - ".github/workflows/merge-mcp-gate-dev.yml" - -permissions: - id-token: write - contents: read - -env: - JF_URL: ${{ vars.JF_URL }} - JF_OIDC_PROVIDER: ${{ vars.JF_OIDC_PROVIDER }} - JF_OIDC_AUDIENCE: ${{ vars.JF_OIDC_AUDIENCE }} - JF_PROJECT: jfml - -jobs: - publish-dev: - name: Publish dev package - runs-on: devf-dind-amd-scale-set - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: main - - # Dev version = -dev., e.g. 0.1.0-dev.42. - # run_number is GitHub's per-workflow counter; it goes up by 1 each - # run, so newer dev packages always sort higher than older ones. - - name: Compute dev version - id: version - run: | - set -e - BASE_VERSION="$(cat mcp-gate/VERSION)" - DEV_VERSION="${BASE_VERSION}-dev.${{ github.run_number }}" - echo "DEV_VERSION=${DEV_VERSION}" >> "$GITHUB_ENV" - echo "Building dev version: ${DEV_VERSION}" - - - name: Install build tools - id: install-tools - uses: JFROG/install-tools@master - with: - install-ngci: 'true' - - - name: Resolve dev generic repo - id: pre-build - uses: JFROG/next-gen-ci-pre-build@v6.0.1 - with: - project: jfml - service-name: jfrog-mcp-gate - short-service-name: jfmcpg - generic: "true" - debug: "true" - - - name: Build install package - run: | - set -e - cd mcp-gate - mkdir -p dist - tar -czf "dist/mcp-gate-${DEV_VERSION}.tgz" bin lib VERSION - ls -la dist/ - - - name: Upload dev package to entplus - env: - JF_ACCESS_TOKEN: ${{ steps.install-tools.outputs.oidc-token }} - TARGET_REPO: ${{ fromJSON(steps.pre-build.outputs.metadata).repositories.generic.deploy }} - run: | - set -e - DEST="${TARGET_REPO}/jfrog-mcp-gate/v${DEV_VERSION}/" - echo "==> Uploading mcp-gate-${DEV_VERSION}.tgz -> ${DEST}" - jfrog rt upload --fail-no-op --quiet --flat=true \ - --project="${JF_PROJECT}" \ - "mcp-gate/dist/mcp-gate-${DEV_VERSION}.tgz" "${DEST}" diff --git a/.github/workflows/release-mcp-gate.yml b/.github/workflows/release-mcp-gate.yml deleted file mode 100644 index e1c9716..0000000 --- a/.github/workflows/release-mcp-gate.yml +++ /dev/null @@ -1,231 +0,0 @@ -# Release jfrog-mcp-gate to Artifactory. -# Edge path: releases.jfrog.io/jfrog-cli-plugins/jfrog-mcp-gate/... -# -# Prereqs (one-time, by JFrog CI infra): -# - Org GitHub vars JF_URL, JF_OIDC_PROVIDER, JF_OIDC_AUDIENCE. -# - Service "jfrog-mcp-gate" (short "jfmcpg") registered with -# next-gen-ci-pre-build under project jfml. - -name: Release jfrog-mcp-gate - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to release (e.g. 0.1.1). Reads mcp-gate/VERSION if empty.' - required: false - type: string - promote_to_latest: - description: 'Also copy this version into jfrog-cli-plugins/jfrog-mcp-gate/latest/ on releases.jfrog.io.' - required: false - default: true - type: boolean - -concurrency: - group: jfrog-mcp-gate-release - cancel-in-progress: false - -permissions: - id-token: write - contents: read - -env: - JF_URL: ${{ vars.JF_URL }} - JF_OIDC_PROVIDER: ${{ vars.JF_OIDC_PROVIDER }} - JF_OIDC_AUDIENCE: ${{ vars.JF_OIDC_AUDIENCE }} - JF_PROJECT: jfml - -jobs: - # 1. Pre-Build: resolve dev generic repo + build metadata. - pre-build: - name: Pre-Build - runs-on: devf-dind-amd-scale-set - timeout-minutes: 15 - outputs: - metadata: ${{ steps.pre-build.outputs.metadata }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.ref }} - - - name: Generate build metadata - id: pre-build - uses: JFROG/next-gen-ci-pre-build@v6.0.1 - with: - project: jfml - service-name: jfrog-mcp-gate - short-service-name: jfmcpg - generic: "true" - validate-dev-repos: "true" - dev-repos-timeout-minutes: "10" - dev-repos-check-interval-seconds: "10" - debug: "true" - - # 2. Build & Upload: tar bin/+lib/+VERSION, push to dev generic on entplus. - build-and-upload: - name: Build & Upload - needs: pre-build - runs-on: devf-dind-amd-scale-set - timeout-minutes: 15 - outputs: - version: ${{ steps.version.outputs.version }} - env: - TARGET_REPO: ${{ fromJSON(needs.pre-build.outputs.metadata).repositories.generic.deploy }} - BUILD_NAME: ${{ fromJSON(needs.pre-build.outputs.metadata).service_name }}-release-${{ fromJSON(needs.pre-build.outputs.metadata).build_type }} - BUILD_NUMBER: ${{ fromJSON(needs.pre-build.outputs.metadata).build_number }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - - - name: Resolve version - id: version - run: | - set -e - if [ -n "${{ inputs.version }}" ]; then - VERSION="${{ inputs.version }}" - else - VERSION="$(cat mcp-gate/VERSION)" - fi - VERSION="${VERSION#v}" - echo "VERSION=${VERSION}" >> "$GITHUB_ENV" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "Releasing jfrog-mcp-gate version: ${VERSION}" - - - name: Install build tools - id: install-tools - uses: JFROG/install-tools@master - with: - install-ngci: 'true' - - - name: Build install package - run: | - set -e - cd mcp-gate - mkdir -p dist - TGZ="mcp-gate-${VERSION}.tgz" - tar -czf "dist/${TGZ}" bin lib VERSION - echo "${VERSION}" > dist/LATEST - ls -la dist/ - - - name: Upload artefacts to Artifactory - env: - JF_ACCESS_TOKEN: ${{ steps.install-tools.outputs.oidc-token }} - run: | - set -e - BASE="${TARGET_REPO}/jfrog-mcp-gate" - - echo "==> Versioned install package -> ${BASE}/v${VERSION}/" - jfrog rt upload --fail-no-op --quiet --flat=true \ - --build-name="${BUILD_NAME}" \ - --build-number="${BUILD_NUMBER}" \ - --project="${JF_PROJECT}" \ - "mcp-gate/dist/mcp-gate-${VERSION}.tgz" "${BASE}/v${VERSION}/" - - echo "==> Top-level artefacts -> ${BASE}/" - for f in install.sh uninstall.sh install.ps1 uninstall.ps1 com.jfrog.mcp-gate.mobileconfig; do - jfrog rt upload --fail-no-op --quiet --flat=true \ - --build-name="${BUILD_NAME}" \ - --build-number="${BUILD_NUMBER}" \ - --project="${JF_PROJECT}" \ - "mcp-gate/${f}" "${BASE}/${f}" - done - jfrog rt upload --fail-no-op --quiet --flat=true \ - --build-name="${BUILD_NAME}" \ - --build-number="${BUILD_NUMBER}" \ - --project="${JF_PROJECT}" \ - "mcp-gate/dist/LATEST" "${BASE}/LATEST" - - - name: Publish build info - env: - JF_ACCESS_TOKEN: ${{ steps.install-tools.outputs.oidc-token }} - run: | - set -e - jfrog rt build-publish \ - "${BUILD_NAME}" \ - "${BUILD_NUMBER}" \ - --project="${JF_PROJECT}" - - # 3. Post-Build: wrap the uploaded build in a signed release bundle and - # promote it to the "release" environment. - post-build: - name: Post-Build - needs: [pre-build, build-and-upload] - runs-on: devf-dind-amd-scale-set - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - - - name: Promote release artefacts - uses: JFROG/next-gen-ci-post-build@v3.3.0 - with: - # Manual mode (no `metadata:`) keeps a fixed bundle name across - # re-dispatches; new versions just stack on top of the same bundle. - bundle-name: jfrog-mcp-gate-release - version: ${{ needs.build-and-upload.outputs.version }} - project: ${{ env.JF_PROJECT }} - builds: '[{"name":"jfrog-mcp-gate-release","number":"${{ fromJSON(needs.pre-build.outputs.metadata).build_number }}"}]' - target-environment: release - create-final-tag: 'false' - - # 4. Distribute: mirror to releases.jfrog.io/jfrog-cli-plugins/. - distribute: - name: Distribute to release edges - needs: [pre-build, build-and-upload, post-build] - runs-on: devf-dind-amd-scale-set - timeout-minutes: 60 - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - - - name: Distribute jfrog-mcp-gate to release edges - uses: JFROG/next-gen-ci-distribution@v2.3.0 - with: - metadata: ${{ needs.pre-build.outputs.metadata }} - service: jfrog-mcp-gate - version: ${{ needs.build-and-upload.outputs.version }} - project: jfml - distribution-type: onprem - distribution-manifest: mcp-gate/.jfrog-distribution.yml - # Package is plain text/JS - skip Xray scan + clamav to keep - # the run fast. Build-info evidence is still attached. - skip-scan: 'true' - skip-clamav: 'true' - - # 5. Promote to "latest" on releases.jfrog.io (opt-in, default ON). - promote-latest: - name: Promote to latest on releases.jfrog.io - if: ${{ inputs.promote_to_latest == true }} - needs: [build-and-upload, post-build, distribute] - runs-on: devf-dind-amd-scale-set - 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@master - with: - install-ngci: 'true' - - - name: Copy versioned install package to latest/ on releases.jfrog.io - env: - JF_ACCESS_TOKEN: ${{ steps.install-tools.outputs.oidc-token }} - run: | - set -e - echo "Copying v${VERSION} to latest/ on releases.jfrog.io..." - jf rt cp --fail-no-op \ - "jfrog-cli-plugins/jfrog-mcp-gate/v${VERSION}/(*)" \ - "jfrog-cli-plugins/jfrog-mcp-gate/latest/{1}" \ - --url="${RELEASES_URL}" \ - --access-token="${JF_ACCESS_TOKEN}" - echo "Done." diff --git a/agent-guard-hook/.jfrog-distribution.yml b/agent-guard-hook/.jfrog-distribution.yml new file mode 100644 index 0000000..47c4680 --- /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 (immutable per version). + - type: generic + path: agent-guard-hook//agent-guard-hook-.tgz + target: + repository: coding-agents-generic + + # Top-level files (rewritten on every release — what IT downloads). + - 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..1426f5c --- /dev/null +++ b/agent-guard-hook/README.md @@ -0,0 +1,139 @@ +# 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 | +| --- | --- | +| push to `master`/`main` (touching `agent-guard-hook/**`) | Release build — versioned archive uploaded, release bundle created and promoted, mirrored to releases.jfrog.io, copied into `latest/`. | +| push to any other branch | Dev build — published to the dev repo. Not distributed. | +| pull_request → `master`/`main` | Dev build only — exercises pre-build → build → post-build end-to-end. Distribution + promote-latest skipped. | +| workflow_dispatch | Same flow, but `build-type` can be overridden (`dev` / `release` / `milestone`). | + +Jobs run in order: `pre-build` → `build-and-upload` → `post-build` → +`distribution` (release only) → `promote-latest` (release only). + +### Cutting a release + +1. Merge the change to `master`. The push triggers the workflow with `build-type: auto`, which detects `release` from the branch. 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. +2. Verify the run in GitHub Actions; the `promote-latest` job is the last step. +3. `install.mjs` now resolves the new version through the `LATEST` file. + +### 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..02ae751 --- /dev/null +++ b/agent-guard-hook/agent-guard-hook.mjs @@ -0,0 +1,370 @@ +#!/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. +// --unregister it (used by uninstall). +// --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; +}; + + +// Require the --registry value parses as an http(s) URL. We don't whitelist +// hostnames — on-prem and customer-owned Artifactory subdomains are legit — +// but rejecting non-URL strings catches typos and obviously bogus values. +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 / --unregister ────────────────────────── + +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 withoutHookEntry = (current) => { + const next = { ...current }; + const existing = next["chat.hookFilesLocations"]; + const locations = existing && typeof existing === "object" ? { ...existing } : {}; + delete locations[HOOK_CONFIG_TILDE]; + if (Object.keys(locations).length === 0) delete next["chat.hookFilesLocations"]; + else 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`); +}; + +const unregister = () => { + if (!isHookRegistered()) { + process.stdout.write(`${PRODUCT_NAME}: not registered, nothing to remove\n`); + return; + } + updateSettings(withoutHookEntry); + process.stdout.write(`${PRODUCT_NAME}: unregistered\n`); +}; + + +// ────────────────────────── entrypoint ────────────────────────── + +const arg = process.argv[2]; +if (arg === "--register") register(); +else if (arg === "--unregister") unregister(); +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..ea8aad8 --- /dev/null +++ b/agent-guard-hook/com.jfrog.agent-guard-hook.mobileconfig @@ -0,0 +1,47 @@ + + + + + + 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..35867e2 --- /dev/null +++ b/agent-guard-hook/install.mjs @@ -0,0 +1,202 @@ +#!/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 HOOK_CONFIG_TILDE = `~/.vscode/hooks/${PRODUCT_NAME}.json`; +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 HOOK_CONFIG = join(HOOK_DIR, `${PRODUCT_NAME}.json`); +const AUDIT_LOG = join(HOOK_DIR, `${PRODUCT_NAME}.log`); + +// VS Code's user-level settings.json (kept in sync with agent-guard-hook.mjs). +const VSCODE_SETTINGS_PATH = (() => { + if (platform() === "darwin") return join(HOME, "Library/Application Support/Code/User/settings.json"); + if (platform() === "win32") return join(process.env.APPDATA ?? join(HOME, "AppData/Roaming"), "Code/User/settings.json"); + return join(HOME, ".config/Code/User/settings.json"); +})(); + +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"); +}; + + +// ────────────────────────── uninstall ────────────────────────── + +// Fallback for when the hook script was already deleted by hand — strip our +// key from settings.json directly so we don't leave a dangling entry behind. +const stripSettingsEntry = () => { + if (!existsSync(VSCODE_SETTINGS_PATH)) return; + let text; + try { text = readFileSync(VSCODE_SETTINGS_PATH, "utf8"); } + catch { return; } + // VS Code allows JSONC; mimic agent-guard-hook.mjs's stripper. + const stripped = text + .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, c) => (c ? "" : m)) + .replace(/,(\s*[}\]])/g, "$1"); + let parsed; + try { parsed = JSON.parse(stripped); } catch { return; } + const locations = parsed?.["chat.hookFilesLocations"]; + if (!locations || typeof locations !== "object" || !(HOOK_CONFIG_TILDE in locations)) return; + delete locations[HOOK_CONFIG_TILDE]; + if (Object.keys(locations).length === 0) delete parsed["chat.hookFilesLocations"]; + const tmp = `${VSCODE_SETTINGS_PATH}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(parsed, null, 2) + "\n", "utf8"); + renameSync(tmp, VSCODE_SETTINGS_PATH); + log(`stripped settings.json entry at ${VSCODE_SETTINGS_PATH}`); +}; + + +const cmdUninstall = () => { + log(`uninstalling ${PRODUCT_NAME}`); + + if (existsSync(HOOK_SCRIPT)) { + spawnSync(process.execPath, [HOOK_SCRIPT, "--unregister"], { stdio: "inherit" }); + } else { + log("hook script not present, cleaning settings.json directly"); + stripSettingsEntry(); + } + + // Archive the audit log rather than delete it — forensics for IT. + if (existsSync(AUDIT_LOG)) { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const archivedLog = `${AUDIT_LOG}.uninstalled-${stamp}`; + renameSync(AUDIT_LOG, archivedLog); + log(`archived audit log → ${archivedLog}`); + } + + for (const path of [HOOK_SCRIPT, HOOK_CONFIG]) { + if (existsSync(path)) { + rmSync(path); + log(`removed ${path}`); + } + } + log("done"); +}; + + +// ────────────────────────── entrypoint ────────────────────────── + +(async () => { + if (flag("--uninstall")) cmdUninstall(); + else await 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 diff --git a/mcp-gate/.jfrog-distribution.yml b/mcp-gate/.jfrog-distribution.yml deleted file mode 100644 index 0b54014..0000000 --- a/mcp-gate/.jfrog-distribution.yml +++ /dev/null @@ -1,35 +0,0 @@ -# Lists the artifacts bundled into a signed release bundle and mirrored to releases.jfrog.io. Read by .github/workflows/release-mcp-gate.yml. -# is filled in at runtime from the workflow's `version:` input - -artifacts: - # Versioned install package (immutable). - - type: generic - path: jfrog-mcp-gate/v/mcp-gate-.tgz - target: - repository: jfrog-cli-plugins - - # Top-level files (overwritten each release - what IT downloads). - - type: generic - path: jfrog-mcp-gate/install.sh - target: - repository: jfrog-cli-plugins - - type: generic - path: jfrog-mcp-gate/uninstall.sh - target: - repository: jfrog-cli-plugins - - type: generic - path: jfrog-mcp-gate/install.ps1 - target: - repository: jfrog-cli-plugins - - type: generic - path: jfrog-mcp-gate/uninstall.ps1 - target: - repository: jfrog-cli-plugins - - type: generic - path: jfrog-mcp-gate/com.jfrog.mcp-gate.mobileconfig - target: - repository: jfrog-cli-plugins - - type: generic - path: jfrog-mcp-gate/LATEST - target: - repository: jfrog-cli-plugins diff --git a/mcp-gate/README.md b/mcp-gate/README.md deleted file mode 100644 index 1b5512b..0000000 --- a/mcp-gate/README.md +++ /dev/null @@ -1,352 +0,0 @@ -# jfrog-mcp-gate - -A VS Code `PreToolUse` hook that allows only MCP tool calls whose server is launched through the JFrog gateway (`npx --yes --registry <…jfrog…> @jfrog/agent-guard …`). Everything else is denied. - -Ships from `releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/` (the same trust boundary the hook itself enforces). - -### Platform support - -The hook code (`bin/*.mjs`, `lib/config.mjs`) is plain Node ≥ 20 and runs on every platform. Only the installer and the per-user service flavor change per OS. - -| | macOS | Linux | Windows | -| --- | --- | --- | --- | -| Hook code | yes | yes | yes | -| Installer | `install.sh` | `install.sh` (same script, auto-detects `uname`) | `install.ps1` (elevated PowerShell) | -| Per-user service | LaunchAgent | `systemd --user` timer (`OnBootSec=10s, OnUnitActiveSec=60s`) | Scheduled Task (at logon + every 1 min) | -| Install root | `/usr/local/jfrog/mcp-gate/` | `/usr/local/jfrog/mcp-gate/` | `%ProgramFiles%\JFrog\mcp-gate\` | -| Audit log | `/var/log/jfrog-mcp-gate.log` (0666) | `/var/log/jfrog-mcp-gate.log` (0666) | `%ProgramData%\JFrog\Logs\jfrog-mcp-gate.log` (Users:Modify) | -| Setup-tick logs | `/Library/Logs/jfrog-mcp-gate/setup.*.log` | `journalctl --user -u jfrog-mcp-user-setup.service` | Task Scheduler history | -| Per-user state | `~/.jfrog/mcp-gate/` | `~/.jfrog/mcp-gate/` | `%USERPROFILE%\.jfrog\mcp-gate\` | -| ChatHooks policy | `com.jfrog.mcp-gate.mobileconfig` | `/etc/vscode/policy.json` | `HKLM\Software\Policies\Microsoft\VSCode\ChatHooks` (Group Policy / Intune) | - -> **Anti-tamper**: The hook config and settings.json entry are heal-on-tick — if a user deletes them the per-user scheduler restores them within ≤60s and writes an `event_type=reseed` audit line. The hook binary itself sits in a root-owned install root that requires sudo/admin to modify; MDM heals that on its check-in. - -## How the pipeline works - -``` - engineer machine GitHub entplus.jfrog.io releases.jfrog.io laptop - ───────────────── ────── ──────────────── ───────────────── ────── - edit code + VERSION - → git push PR build (optional) ─ ─ ─ - ↓ - merge to main - ↓ - merge-mcp-gate-dev.yml → dev-master-generic-local/ ─ ─ - (fires automatically) jfrog-mcp-gate/v-dev./ - mcp-gate--dev..tgz - (engineers can pull this for staging tests) - - release-mcp-gate.yml → dev-master-generic-local/... jfrog-cli-plugins/ IT's MDM re-runs - (manual: bump VERSION, → release bundle promote jfrog-mcp-gate/v/ install.sh on its - click "Run workflow") → distribute to edges mcp-gate-.tgz schedule and pulls - + install.sh, uninstall.sh, the new version - LATEST, .mobileconfig -``` - -Two workflows: - -| Workflow | Trigger | Lands on | -| --- | --- | --- | -| `merge-mcp-gate-dev.yml` | Every merge to `main` | entplus dev master generic (`dev-master-generic-local/jfrog-mcp-gate/v-dev./`) | -| `release-mcp-gate.yml` | Manual (`workflow_dispatch`) | `releases.jfrog.io/jfrog-cli-plugins/jfrog-mcp-gate/v/` and the top-level `install.sh` / `uninstall.sh` / `LATEST` / `.mobileconfig` | - -Until JFrog CI infra onboards the repo, `poc/release.sh` is the local-engineer fallback (uses `jf rt upload` directly). - -## Files in this repo - -### Top level — what IT and laptops consume - -| File | Owner | Purpose | -| --- | --- | --- | -| `install.sh` | IT, MDM (macOS + Linux). Engineers locally. | Auto-detects `uname` and dispatches macOS vs. Linux. Default: download the latest install package (`mcp-gate-.tgz`) from Artifactory. With `--package `: install from a local `.tgz` file (engineer testing). | -| `uninstall.sh` | IT, support (macOS + Linux) | Removes everything `install.sh` wrote + per-user state for the logged-in user. Audit log preserved. | -| `install.ps1` | IT, MDM (Windows). Engineers locally. | PowerShell equivalent of `install.sh`. Must be run from an elevated PowerShell. Same `-Package` flag for local testing. | -| `uninstall.ps1` | IT, support (Windows) | PowerShell equivalent of `uninstall.sh`. | -| `com.jfrog.mcp-gate.mobileconfig` | IT, MDM (macOS only) | macOS configuration profile. Sets `ChatHooks=true` so users can't disable VS Code hooks. Pushed via Jamf/Intune/Munki. | -| `VERSION` | Engineer | The only metadata you edit when cutting a release. | -| `.jfrog-distribution.yml` | CI | Required by `JFROG/next-gen-ci-distribution` in the release workflow. Lists the artefacts to bundle into a release bundle and push to the edge. | - -### `bin/` — the binaries that get installed - -| File | Purpose | -| --- | --- | -| `bin/jfrog-mcp-gate.mjs` | **The hook.** VS Code spawns it with a `PreToolUse` payload on every chat tool call. Reads `mcp.json`, validates the launch command against `lib/config.mjs`, exits `0` (allow) or `2` (deny). No flags — the hook is a pure stdin→exit-code program. | -| `bin/jfrog-setup-user.mjs` | **Per-user setup.** Run by the per-user service at login + every 60s. Writes `~/.jfrog/mcp-gate/vscode-hooks.json`, adds the `chat.hookFilesLocations` entry to `settings.json`, applies macOS locks. One flag: `--clean` (reverse of a tick — used by uninstallers). | - -### `lib/` — the shared module - -| File | Purpose | -| --- | --- | -| `lib/config.mjs` | Single shared module: policy (`POLICY`), OS-specific paths, hook-config payload, JSONC helpers, audit logger. Both binaries import only this file. | - -### `poc/` — engineer-local fallback (delete once CI infra is in place) - -| File | Purpose | -| --- | --- | -| `poc/release.sh` | Builds the install package (`mcp-gate-.tgz`) and `jf rt upload`s it to `JFROG_MCP_GATE_REPO` (default `jfrog-cli-plugins/jfrog-mcp-gate`). Same outputs as the GH Action — produces `dist/mcp-gate-.tgz` and `dist/LATEST`. Supports `--dry-run` to build the file without uploading. | - -### `.github/workflows/` — at the repo root - -| File | Purpose | -| --- | --- | -| `merge-mcp-gate-dev.yml` | Auto-publishes the dev install package (`mcp-gate--dev..tgz`) to entplus dev master generic on every merge to main. | -| `release-mcp-gate.yml` | Manual-trigger production release. Pre-Build → Build & Upload → Post-Build (release bundle + promote) → Distribute → Promote-Latest. | - -## What gets installed on each laptop - -### macOS - -Root-owned (by `install.sh`): - -``` -/usr/local/jfrog/mcp-gate/bin/jfrog-mcp-gate.mjs the hook -/usr/local/jfrog/mcp-gate/bin/jfrog-setup-user.mjs per-user setup -/usr/local/jfrog/mcp-gate/lib/config.mjs policy + helpers -/usr/local/jfrog/mcp-gate/VERSION -/Library/LaunchAgents/com.jfrog.mcp-user-setup.plist -/var/log/jfrog-mcp-gate.log audit log (0666) -``` - -MDM-pushed (from `.mobileconfig`): -`/Library/Managed Preferences/com.microsoft.VSCode.plist` → `ChatHooks=true`. - -Per-user state (heal-on-tick by the LaunchAgent every 60s): - -``` -~/.jfrog/mcp-gate/vscode-hooks.json -chat.hookFilesLocations entry in ~/Library/Application Support/Code/User/settings.json -``` - -### Linux - -Root-owned (by `install.sh`): - -``` -/usr/local/jfrog/mcp-gate/... same layout as macOS -/etc/systemd/user/jfrog-mcp-user-setup.service per-user oneshot -/etc/systemd/user/jfrog-mcp-user-setup.timer OnBootSec=10s, OnUnitActiveSec=60s -/var/log/jfrog-mcp-gate.log audit log (0666) -``` - -MDM-pushed: `/etc/vscode/policy.json` containing `{"ChatHooks": true}`. - -Per-user state (no kernel-level lock): - -``` -~/.jfrog/mcp-gate/vscode-hooks.json -chat.hookFilesLocations entry in ~/.config/Code/User/settings.json -``` - -### Windows - -Admin-owned (by `install.ps1`): - -``` -%ProgramFiles%\JFrog\mcp-gate\bin\jfrog-mcp-gate.mjs the hook -%ProgramFiles%\JFrog\mcp-gate\bin\jfrog-setup-user.mjs per-user setup -%ProgramFiles%\JFrog\mcp-gate\lib\config.mjs policy + helpers -%ProgramFiles%\JFrog\mcp-gate\VERSION -Scheduled Task "JFrogMcpUserSetup" at logon + every 1 min -%ProgramData%\JFrog\Logs\jfrog-mcp-gate.log audit log (Users:Modify) -``` - -MDM-pushed: `HKLM\Software\Policies\Microsoft\VSCode\ChatHooks` (REG_DWORD, 1) — via Group Policy / Intune. - -Per-user state (no kernel-level lock): - -``` -%USERPROFILE%\.jfrog\mcp-gate\vscode-hooks.json -chat.hookFilesLocations entry in %APPDATA%\Code\User\settings.json -``` - -## Demo outcomes - -The hook walks every `mcp.json` VS Code can load (user profile + workspace + ancestor `.vscode/mcp.json`) and validates each launch command against `lib/config.mjs`. - -| Demo case | Expected outcome | -| --- | --- | -| MCP through the gateway: `npx --yes --registry @jfrog/agent-guard …` | **ALLOW**, audit reason `npx + @jfrog/agent-guard + --registry `. | -| MCP launched outside the gateway: `"command": "node"` | **DENY**, audit reason `… (command 'node' must be 'npx')`. | -| MCP with the old `@jfrog/mcp-gateway` | **DENY**, audit reason `… (missing required arg '@jfrog/agent-guard')`. | -| Extension-registered MCP (e.g. bundled PostgreSQL MCP) | **DENY**, audit reason `server not found in mcp.json - extension-registered MCPs are not gateway-served`. | -| Non-MCP tools (`run_in_terminal`, `read_file`) | **ALLOW**, audit reason `non-MCP tool, out of scope`. | - -## Enforcement - -| Bypass attempt | Outcome | -| --- | --- | -| User sets `chat.useHooks=false` | MDM `ChatHooks=true` policy overrides. | -| User deletes `chat.hookFilesLocations` from `settings.json` | Setup-user re-adds it ≤60s (`event_type=reseed`). | -| User `rm ~/.jfrog/mcp-gate/vscode-hooks.json` | Works once; setup-user rewrites it on the next tick (≤60s). | -| User deletes the hook binary in `/usr/local/jfrog/…` (or Windows equivalent) | Requires sudo/admin. MDM reruns `install.sh` on next check-in. | -| User unloads the LaunchAgent / disables the Scheduled Task / stops the timer | Requires sudo/admin. MDM re-registers it on next check-in. | - -## Audit log - -Every decision is one JSON line in `/var/log/jfrog-mcp-gate.log`. Fields: `ts`, `product`, `version`, `event_type` (`decision` / `reseed` / `setup_user_tick`), `tool_use_id`, `tool_name`, `server`, `decision` (`allow` / `deny`), `reason`. - -```sh -tail -f /var/log/jfrog-mcp-gate.log | jq -c 'select(.event_type=="decision")' -``` - -## Adjusting the policy - -`lib/config.mjs` is the single source of truth: - -```js -export const POLICY = { - command: "npx", - required_args: ["--yes", "@jfrog/agent-guard"], - registry_arg: "--registry", -}; -``` - -We require the `--registry ` pair (Agent Guard can't run without -it) but we don't restrict the URL value — different customers point at -different repos. - -After editing, bump `VERSION` and trigger the release workflow. - ---- - -## Three flows - -### Flow 1 — test it locally in VS Code (engineer) - -Six steps. Same `install.sh` IT runs in production, just pointed at a locally-built `.tgz` file instead of Artifactory. - -```sh -cd /Users/yanivt/Jfrog/vscode-plugin/mcp-gate - -# 1. Clean slate (remove any previous install, including locks). -sudo ./uninstall.sh - -# 2. Build the install package. Produces dist/mcp-gate-.tgz. -./poc/release.sh --dry-run - -# 3. Install from that local package. You'll be asked for your sudo password. -sudo ./install.sh --package dist/mcp-gate-0.1.0.tgz - -# 4. In a second terminal, watch the audit log: -tail -f /var/log/jfrog-mcp-gate.log | jq -c 'select(.event_type=="decision")' - -# 5. Open VS Code → Copilot Chat → trigger MCP tool calls: -# - Allowed: any MCP server you've configured to launch via -# "command": "npx", "args": ["--yes", "--registry", -# "", "@jfrog/agent-guard", ...] -# - Denied: anything else (extension-registered MCP, "command": "node", -# missing "--registry " pair, missing @jfrog/agent-guard). - -# 6. Tamper test (verifies the heal-on-tick scheduler works): -rm ~/.jfrog/mcp-gate/vscode-hooks.json -# Within 60s the LaunchAgent rewrites the file and writes -# `event_type=reseed` to the audit log. - -# 7. Clean up when done. -sudo ./uninstall.sh -``` - -Quick smoke without going through VS Code — feed the hook a fake VS Code payload: - -```sh -echo '{"tool_name":"mcp_chrome-devtools-mcp_new_page","cwd":"'$PWD'"}' \ - | node bin/jfrog-mcp-gate.mjs && echo "ALLOW" || echo "DENY" -tail -1 /var/log/jfrog-mcp-gate.log | jq . -``` - -### Flow 2 — release a new version (engineer) - -```sh -# 1. (Optional) Edit code, e.g. tweak the policy in lib/config.mjs. -# 2. Bump VERSION (the only metadata you change). -echo "0.1.1" > mcp-gate/VERSION -# 3. Commit + push. -git commit -am "mcp-gate: 0.1.1 - widen registry regex" -git push -``` - -What happens automatically and what's manual: - -- **On merge to `main`** → `merge-mcp-gate-dev.yml` fires. Publishes `mcp-gate-0.1.1-dev..tgz` to entplus dev master generic. Engineers can pull from there for staging tests. -- **Manual when ready to ship** → GitHub → Actions → `Release jfrog-mcp-gate` → Run workflow (`promote_to_latest: true`). Ships `0.1.1` to `releases.jfrog.io/jfrog-cli-plugins/jfrog-mcp-gate/v0.1.1/` and refreshes the top-level files IT downloads. - -POC fallback (only until CI infra is onboarded): - -```sh -cd mcp-gate -./poc/release.sh --dry-run # build only -./poc/release.sh # actually push to JFROG_MCP_GATE_REPO via `jf` -``` - -### Flow 3 — deploy to N laptops (IT) - -Two steps per laptop, wrapped by Jamf/Intune/Munki/Group-Policy. **The OS-specific bit is only the policy push + the one-liner**; the rest (Artifactory, versioning, rollback) is identical across all three. - -#### macOS - -```sh -# 1. Push com.jfrog.mcp-gate.mobileconfig via Jamf/Intune (sets ChatHooks=true). -# 2. Run the installer (typically scheduled to re-run every 30 min). -curl -sSfL https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/install.sh \ - | sudo bash -``` - -#### Linux - -```sh -# 1. Write the ChatHooks=true VS Code policy. -sudo install -m 0644 /dev/stdin /etc/vscode/policy.json <<<'{"ChatHooks": true}' -# 2. Run the installer (same script auto-detects Linux). -curl -sSfL https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/install.sh \ - | sudo bash -``` - -#### Windows - -```powershell -# 1. Push ChatHooks=1 via Group Policy (preferred) or directly: -reg add HKLM\Software\Policies\Microsoft\VSCode /v ChatHooks /t REG_DWORD /d 1 /f -# 2. Run the installer from an elevated PowerShell. -iwr -useb https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/install.ps1 | iex -``` - -#### Common to all three - -Updates are automatic — each re-run reads `/LATEST` from Artifactory and reinstalls only if the version changed. To roll back or stage a specific build, download the `.tgz` directly and pass `--package`: - -```sh -# macOS + Linux — pin to v0.1.0 -curl -sSfLO https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/v0.1.0/mcp-gate-0.1.0.tgz -sudo ./install.sh --package ./mcp-gate-0.1.0.tgz - -# Windows — same idea -iwr -useb https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/v0.1.0/mcp-gate-0.1.0.tgz -OutFile mcp-gate-0.1.0.tgz -.\install.ps1 -Package .\mcp-gate-0.1.0.tgz -``` - -Uninstall a laptop: - -```sh -# macOS + Linux -curl -sSfL https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/uninstall.sh \ - | sudo bash - -# Windows (elevated PowerShell) -iwr -useb https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/uninstall.ps1 | iex -``` - -Dev install packages live under `entplus.jfrog.io/.../dev-master-generic-local/jfrog-mcp-gate/v-dev./`. To stage one, download the `.tgz` from there and pass `--package`/`-Package` as above. - -## Prerequisites - -- macOS (Apple Silicon or Intel), Linux with systemd, or Windows 10+. -- VS Code ≥ 1.109 (`ChatHooks` enterprise policy shipped in 1.109). -- Node.js ≥ 20 on `PATH` (`node.exe` on Windows). -- macOS: nothing extra. Linux: `tar`, `curl`, `systemd --user`. Windows: PowerShell 5.1+ (or PowerShell 7), `tar.exe` (ships with Windows 10+). - -## Deferred - -- **Filesystem-level anti-tamper.** Today the user-level files (`~/.jfrog/mcp-gate/vscode-hooks.json` and the `chat.hookFilesLocations` entry in `settings.json`) are healed by the per-user scheduler every 60s, so a deletion only opens a ≤60s bypass window. Adding `chflags uchg` (macOS), `chattr +i` (Linux), or NTFS DENY ACLs would slow casual tampering, but each would require root/admin to apply, which a user-mode setup process can't do — so the heal-on-tick model is the cross-platform defense. -- **Non-default VS Code profiles.** The hook scans only the default profile's `mcp.json` (`/mcp.json`) plus workspace `.vscode/mcp.json` files. If a user creates a named profile (`/profiles//mcp.json`) and runs MCP servers from it, those servers won't be found → all their tool calls get denied. Rare in practice; we'll wire up profile discovery if it bites someone. -- **Validated on real Linux + Windows boxes.** The installers were authored on macOS. Linux + Windows runs need a smoke test pass before IT picks them up. -- **Signed `.pkg` (macOS) and signed `.msi` (Windows).** Today's distribution is the raw `.tgz` install package + the shell/PowerShell scripts; signed installer bundles are the natural next step once CI infra is in place. diff --git a/mcp-gate/VERSION b/mcp-gate/VERSION deleted file mode 100644 index 6e8bf73..0000000 --- a/mcp-gate/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1.0 diff --git a/mcp-gate/bin/jfrog-mcp-gate.mjs b/mcp-gate/bin/jfrog-mcp-gate.mjs deleted file mode 100755 index c67ed00..0000000 --- a/mcp-gate/bin/jfrog-mcp-gate.mjs +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env node -// jfrog-mcp-gate — VS Code PreToolUse hook. -// -// On every chat tool call, VS Code spawns this script, pipes a JSON -// payload into its stdin, and reads our exit code: -// exit 0 = allow the tool call -// exit 2 = deny the tool call -// -// What this script does: -// Step 1. Read VS Code's payload from stdin. -// Step 2. Find every mcp.json VS Code could load -// (user-level + workspace + ancestor folders). -// Step 3. Merge them into one server list (user-level wins on conflict). -// Step 4. Figure out which server the tool call came from. -// Step 5. Validate that server's launch command against the policy. -// Step 6. Write one audit-log line and exit. - -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, join, resolve as resolvePath } from "node:path"; - -import { - POLICY, - PRODUCT_NAME, - VSCODE_USER_DIR, - audit, - parseJsonc, -} from "../lib/config.mjs"; - -// Constants -const HOME = homedir(); -const MCP_TOOL_PREFIX = "mcp_"; - - -// Step 1. Read VS Code's JSON payload from stdin. -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)); - }); - - -// Step 2. Find every mcp.json VS Code could load. -// Order matters — `collectServers` below is "first wins". We list the user-level mcp.json FIRST because Agent Guard's -// flow writes the trusted server entry there, and we want that entry to be the one we validate against -// `cwd` is the workspace folder VS Code told us about. After the user-level file we walk upward from `cwd` toward -// $HOME, picking up any `.vscode/mcp.json` along the way. -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 until we hit $HOME or "/". - if (cwd) { - let dir = resolvePath(cwd); - const stopAt = resolvePath(HOME, ".."); - while (dir && dir !== "/" && dir !== stopAt) { - addIfExists(join(dir, ".vscode/mcp.json")); - const parent = dirname(dir); - if (parent === dir) break; // reached filesystem root - dir = parent; - } - } - return paths; -}; - - -// Read + parse a JSONC file. Returns null on any error so the caller -// can skip an unparseable mcp.json without crashing the hook. -const readMcpJson = (path) => { - try { return parseJsonc(readFileSync(path, "utf8")); } catch { return null; } -}; - - -// Step 3. Merge all servers from the found mcp.json files into one dict. "First wins": if two mcp.json files both -// define a server named "chrome", the FIRST one in `mcpJsonPaths` takes priority. Because Step 2 puts the user-level -// mcp.json first, the user-level entry wins over any workspace override -const collectServers = (mcpJsonPaths) => { - const serversByName = Object.create(null); - for (const mcpJsonPath of mcpJsonPaths) { - const mcpJson = readMcpJson(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; -}; - - -// Step 4. Tool name → server name. -// VS Code tool names look like: "mcp__" -// e.g. server "chrome-devtools-mcp" → tool "mcp_chrome-devtoo_new_page" -// We walk both strings side-by-side until they diverge; the server whose -// prefix matches the longest stretch of the tool name 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); - - // Count how many leading chars match. - let matchedLen = 0; - while (matchedLen < maxLen && sanitized[matchedLen] === toolSuffix[matchedLen]) matchedLen++; - if (matchedLen === 0) continue; - - // Right after the match the tool name must be "_" (separator) or end. - const fits = matchedLen === toolSuffix.length || toolSuffix[matchedLen] === "_"; - if (fits && matchedLen > bestLength) { - bestName = serverName; - bestLength = matchedLen; - } - } - return bestName; -}; - - -// Step 5. Check the server's launch command against POLICY. -// Returns null on a successful match. Otherwise returns a short string saying which part of the policy failed. -// We require: command=npx, "--yes", "@jfrog/agent-guard", and a "--registry " pair. -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`; - } - return null; -}; - - -// Step 6. Emit one audit-log line and exit. -// deny → write to stderr (VS Code shows the reason) and exit 2. -// allow → silent and 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); -}; - - -// The hook. -const main = async () => { - 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 ?? ""; - - // VS Code sends cwd in the payload. process.cwd() is the fallback for command-line testing - const cwd = request.cwd ?? process.cwd(); - - // Non-MCP tools (run_in_terminal, read_file, memory, …) 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)); - - // Server not in any mcp.json. Deny. - 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, - }); -}; - - -main().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/mcp-gate/bin/jfrog-setup-user.mjs b/mcp-gate/bin/jfrog-setup-user.mjs deleted file mode 100755 index 8ce9790..0000000 --- a/mcp-gate/bin/jfrog-setup-user.mjs +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env node -// jfrog-setup-user — per-user setup. Idempotent. Runs at login + every 60s. -// -// Two modes: -// (default) apply one tick: write hook config + update settings.json. -// --clean strip our entry from settings.json (used by uninstall). -// "Idempotent" = if disk already matches the target, do nothing. -// "Tick" = one pass of the setup loop. - -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { homedir, platform } from "node:os"; -import { dirname, join, resolve as resolvePath } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - HOOK_CONFIG_TILDE, - PRODUCT_NAME, - VSCODE_SETTINGS_PATH, - audit, - buildHookConfig, - stripJsonc, -} from "../lib/config.mjs"; - -// Constants -const IS_MAC= platform() === "darwin"; -const IS_WIN= platform() === "win32"; -const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); // folder this script lives in -const HOOK_BIN = process.env.JFROG_MCP_GATE_HOOK_BIN ?? resolvePath(SCRIPT_DIR, "jfrog-mcp-gate.mjs"); -const USER_HOME = process.env.JFROG_MCP_GATE_HOME ?? homedir(); -const MCP_GATE_DIR = join(USER_HOME, ".jfrog", "mcp-gate"); -const HOOK_CONFIG = join(MCP_GATE_DIR, "vscode-hooks.json"); - -const SETTINGS_PATH = process.env.JFROG_MCP_GATE_HOME - ? (IS_MAC ? join(USER_HOME, "Library/Application Support/Code/User/settings.json") - : IS_WIN ? join(USER_HOME, "AppData/Roaming/Code/User/settings.json") - : join(USER_HOME, ".config/Code/User/settings.json")) - : VSCODE_SETTINGS_PATH; - -const SETTINGS_INDENT = 2; - -// Read settings.json. Returns {} if the file doesn't exist. -const readSettings = () => { - if (!existsSync(SETTINGS_PATH)) return {}; - try { return JSON.parse(stripJsonc(readFileSync(SETTINGS_PATH, "utf8"))) ?? {}; } - catch (err) { - process.stderr.write(`${PRODUCT_NAME}: cannot parse ${SETTINGS_PATH}: ${err.message}\nFix manually and rerun.\n`); - process.exit(1); - } -}; - - -// Atomic write: write to a sibling temp file, then rename it onto the -// target. Used for both vscode-hooks.json and settings.json. -const atomicWrite = (path, text) => { - mkdirSync(dirname(path), { recursive: true }); - const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tmp, text, "utf8"); - renameSync(tmp, path); -}; - - -// Pure transforms on the parsed settings.json. -const reseededSettings = (current) => { - const next = { ...current }; - delete next["chat.hooks.enabled"]; // legacy stray key from earlier VS Code versions - const existing = next["chat.hookFilesLocations"]; - const locations = existing && typeof existing === "object" ? { ...existing } : {}; - locations[HOOK_CONFIG_TILDE] = true; - next["chat.hookFilesLocations"] = locations; - return next; -}; - -const cleanedSettings = (current) => { - const next = { ...current }; - delete next["chat.hooks.enabled"]; - const existing = next["chat.hookFilesLocations"]; - const locations = existing && typeof existing === "object" ? { ...existing } : {}; - delete locations[HOOK_CONFIG_TILDE]; - if (Object.keys(locations).length === 0) delete next["chat.hookFilesLocations"]; - else next["chat.hookFilesLocations"] = locations; - return next; -}; - - -// stringify the target, read the file, compare. If they match we skip the write. -const tick = () => { - const targetHookText= JSON.stringify(buildHookConfig(HOOK_BIN), null, 2) + "\n"; - const targetSettingsText= JSON.stringify(reseededSettings(readSettings()), null, SETTINGS_INDENT) + "\n"; - - const currentHookText= existsSync(HOOK_CONFIG) ? readFileSync(HOOK_CONFIG,"utf8") : null; - const currentSettingsText= existsSync(SETTINGS_PATH) ? readFileSync(SETTINGS_PATH, "utf8") : ""; - - const hookDrifted= currentHookText !== targetHookText; - const settingsDrifted= currentSettingsText !== targetSettingsText; - - if (!hookDrifted && !settingsDrifted) { - audit({ event_type: "setup_user_tick", changed: false }); - return; - } - - if (hookDrifted) { - atomicWrite(HOOK_CONFIG, targetHookText); - audit({ event_type: "reseed", target: HOOK_CONFIG, reason: currentHookText == null ? "created" : "updated" }); - } - if (settingsDrifted) { - atomicWrite(SETTINGS_PATH, targetSettingsText); - audit({ event_type: "reseed", target: SETTINGS_PATH, reason: "applied chat.hookFilesLocations" }); - } - - audit({ event_type: "setup_user_tick", changed: true }); -}; - - -// --clean — the reverse of a tick. -// Strip our chat.hookFilesLocations entry from settings.json. The hook-config directory itself is removed by the -// OS uninstaller, not here. -const clean = () => { - if (!existsSync(SETTINGS_PATH)) return; - const currentText = readFileSync(SETTINGS_PATH, "utf8"); - const nextText = JSON.stringify(cleanedSettings(readSettings()), null, SETTINGS_INDENT) + "\n"; - if (currentText !== nextText) atomicWrite(SETTINGS_PATH, nextText); -}; - - -// Entrypoint -if (process.argv[2] === "--clean") clean(); -else tick(); diff --git a/mcp-gate/com.jfrog.mcp-gate.mobileconfig b/mcp-gate/com.jfrog.mcp-gate.mobileconfig deleted file mode 100644 index 2c87a65..0000000 --- a/mcp-gate/com.jfrog.mcp-gate.mobileconfig +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - PayloadContent - - - PayloadDisplayName - Visual Studio Code - JFrog MCP Gate - PayloadIdentifier - com.jfrog.mcp-gate.vscode.629FDCB6-2870-464A-9D0D-29BB69BAA49E - PayloadType - com.microsoft.VSCode - PayloadUUID - 629FDCB6-2870-464A-9D0D-29BB69BAA49E - PayloadVersion - 1 - ChatHooks - - - - PayloadDescription - Forces VS Code chat hooks ON so the JFrog MCP gate PreToolUse hook cannot be disabled by users. - PayloadDisplayName - JFrog MCP Gate - PayloadIdentifier - com.jfrog.mcp-gate - PayloadOrganization - JFrog Ltd. - PayloadRemovalDisallowed - - PayloadScope - System - PayloadType - Configuration - PayloadUUID - 6F3E5914-1350-4637-86B5-A8B3006035D9 - PayloadVersion - 1 - - diff --git a/mcp-gate/install.ps1 b/mcp-gate/install.ps1 deleted file mode 100644 index 6a836a4..0000000 --- a/mcp-gate/install.ps1 +++ /dev/null @@ -1,141 +0,0 @@ -# jfrog-mcp-gate installer (Windows). macOS / Linux: see install.sh. -# Production: iwr -useb https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/install.ps1 | iex -# (must run in an elevated PowerShell — "Run as Administrator") -# Local test: .\install.ps1 -Package .\dist\mcp-gate-.tgz -#Requires -RunAsAdministrator - -[CmdletBinding()] -param( - [string]$Package = "" -) - -$ErrorActionPreference = "Stop" - -# Settings — the upstream URL is baked in. To install from a local .tgz -# instead of Artifactory pass -Package . -$Url = "https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate" -$InstallRoot = Join-Path $env:ProgramFiles "JFrog\mcp-gate" -$LogDir = Join-Path $env:ProgramData "JFrog\Logs" -$AuditLog = Join-Path $LogDir "jfrog-mcp-gate.log" -$TaskName = "JFrogMcpUserSetup" - -# Preflight — node (for the hook + setup-user at runtime) and tar (for unpacking the .tgz). Windows 10+ ships tar.exe. -if (-not (Get-Command node -ErrorAction SilentlyContinue)) { - throw "install.ps1: 'node' not on PATH (need Node.js >= 20)." -} -if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { - throw "install.ps1: 'tar' not on PATH (Windows 10+ ships tar.exe; install BSD tar otherwise)." -} - -# Stage the payload in a temp dir we clean up at the end (try/finally below). -# Ends up with bin\, lib\, VERSION after the tar extracts. -$Stage = Join-Path $env:TEMP ("jfrog-mcp-gate-" + [guid]::NewGuid()) -New-Item -ItemType Directory -Path $Stage -Force | Out-Null - -try { - $payload = Join-Path $Stage "payload.tgz" - - if ($Package) { - if (-not (Test-Path $Package)) { throw "install.ps1: package not found: $Package" } - Write-Host "==> Installing from local package: $Package" - Copy-Item $Package $payload - } else { - # Resolve "latest" by fetching the LATEST file (one line of text, - # the version number). Then download mcp-gate-.tgz. - Write-Host "==> Resolving latest version from $Url/LATEST" - $Version = (Invoke-WebRequest -UseBasicParsing -Uri "$Url/LATEST").Content.Trim() - if (-not $Version) { throw "install.ps1: could not resolve version." } - Write-Host "==> Installing jfrog-mcp-gate $Version from $Url" - Invoke-WebRequest -UseBasicParsing -Uri "$Url/v$Version/mcp-gate-$Version.tgz" -OutFile $payload - } - - tar -xzf $payload -C $Stage - Remove-Item $payload - - foreach ($p in @("bin\jfrog-mcp-gate.mjs", "bin\jfrog-setup-user.mjs", "lib\config.mjs", "VERSION")) { - if (-not (Test-Path (Join-Path $Stage $p))) { throw "install.ps1: payload missing $p" } - } - - # Lay down the install root — Program Files inherits ACLs that give - # Administrators write / Users read+execute, exactly what we want. - Write-Host "==> Installing into $InstallRoot" - if (Test-Path $InstallRoot) { Remove-Item -Recurse -Force $InstallRoot } - New-Item -ItemType Directory -Path (Join-Path $InstallRoot "bin") -Force | Out-Null - New-Item -ItemType Directory -Path (Join-Path $InstallRoot "lib") -Force | Out-Null - - Copy-Item (Join-Path $Stage "bin\jfrog-mcp-gate.mjs") (Join-Path $InstallRoot "bin\jfrog-mcp-gate.mjs") - Copy-Item (Join-Path $Stage "bin\jfrog-setup-user.mjs") (Join-Path $InstallRoot "bin\jfrog-setup-user.mjs") - Copy-Item (Join-Path $Stage "lib\config.mjs") (Join-Path $InstallRoot "lib\config.mjs") - Copy-Item (Join-Path $Stage "VERSION") (Join-Path $InstallRoot "VERSION") - - # Audit log — grant the BUILTIN\Users group Modify so the user-mode - # hook + setup-user can append. ProgramData defaults are tighter. - New-Item -ItemType Directory -Path $LogDir -Force | Out-Null - if (-not (Test-Path $AuditLog)) { New-Item -ItemType File -Path $AuditLog -Force | Out-Null } - $acl = Get-Acl $AuditLog - $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( - "BUILTIN\Users", "Modify", "Allow") - $acl.AddAccessRule($rule) - Set-Acl $AuditLog $acl - - # Scheduled Task — Windows' equivalent of LaunchAgent/systemd-timer. - # Runs at logon + every 1 min as the interactive user (so setup-user - # can write under %USERPROFILE%\.jfrog\ and edit their settings.json). - $setupBin = Join-Path $InstallRoot "bin\jfrog-setup-user.mjs" - $action = New-ScheduledTaskAction -Execute "node.exe" -Argument "`"$setupBin`"" - $atLogon = New-ScheduledTaskTrigger -AtLogOn - - # Windows doesn't have a "every-N-minutes-forever" trigger directly. - # Workaround: a one-shot trigger starting in 1 min, with a 1-min - # repetition interval and a very long repetition duration (~1 year). - $repeat = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` - -RepetitionInterval (New-TimeSpan -Minutes 1) ` - -RepetitionDuration (New-TimeSpan -Hours 9999) - - # Task settings — survive battery transitions so the heal-on-tick - # keeps working on a laptop. - $settings = New-ScheduledTaskSettingsSet ` - -AllowStartIfOnBatteries ` - -DontStopIfGoingOnBatteries ` - -StartWhenAvailable - - # Run the task as whoever is interactively logged in (not SYSTEM, not - # a specific account). S-1-5-32-545 = the built-in "Users" group SID. - $principal = New-ScheduledTaskPrincipal -GroupId "S-1-5-32-545" - - # Unregister-then-register replaces any older task with the same name, - # so reinstalls don't accumulate stale schedules. - Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue - Register-ScheduledTask ` - -TaskName $TaskName ` - -Description "JFrog mcp-gate per-user setup (logon + every 60s)" ` - -Action $action ` - -Trigger @($atLogon, $repeat) ` - -Settings $settings ` - -Principal $principal | Out-Null - - # Kick the task once so the user doesn't have to wait for the timer. - Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue - -} finally { - Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue -} - -# Done — print a "what next" hint - -$installed = (Get-Content (Join-Path $InstallRoot "VERSION") -Raw).Trim() -Write-Host "" -Write-Host "==> Installed jfrog-mcp-gate $installed (windows)." -Write-Host "" -Write-Host "Per-user state appears on the next task tick (<=60s):" -Write-Host " %USERPROFILE%\.jfrog\mcp-gate\vscode-hooks.json" -Write-Host " chat.hookFilesLocations entry in VS Code user settings" -Write-Host "" -Write-Host "Next:" -Write-Host " 1. Push ChatHooks=1 via Group Policy" -Write-Host " HKLM\Software\Policies\Microsoft\VSCode\ChatHooks (REG_DWORD, 1)" -Write-Host " 2. Restart VS Code." -Write-Host " 3. Get-Content -Tail 0 -Wait $AuditLog" -Write-Host "" -Write-Host "Uninstall:" -Write-Host " iwr -useb $Url/uninstall.ps1 | iex" diff --git a/mcp-gate/install.sh b/mcp-gate/install.sh deleted file mode 100755 index dab0792..0000000 --- a/mcp-gate/install.sh +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env bash -# jfrog-mcp-gate installer (macOS + Linux). -# Production: curl -sSfL https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate/install.sh | sudo bash -# Local test: sudo ./install.sh --package dist/mcp-gate-.tgz - -set -euo pipefail - -# Settings — paths and the upstream URL are baked in. To install from a -# local .tgz instead of Artifactory pass --package . -URL="https://releases.jfrog.io/artifactory/jfrog-cli-plugins/jfrog-mcp-gate" -INSTALL_ROOT="/usr/local/jfrog/mcp-gate" -AUDIT_LOG="/var/log/jfrog-mcp-gate.log" - -# Parse CLI args -LOCAL_PACKAGE="" -while [[ $# -gt 0 ]]; do - case "$1" in - --package) LOCAL_PACKAGE="$2"; shift 2 ;; - -h|--help) sed -n '2,5p' "$0"; exit 0 ;; - *) echo "install.sh: unknown arg '$1' (try --help)" >&2; exit 1 ;; - esac -done - -# OS dispatch — macOS uses LaunchAgents (in /Library/LaunchAgents) and the wheel group; Linux uses systemd --user -# units and group root. -case "$(uname -s)" in - Darwin) PLATFORM=macos; GROUP=wheel; SETUP_LOG_DIR="/Library/Logs/jfrog-mcp-gate" ;; - Linux) PLATFORM=linux; GROUP=root; SETUP_LOG_DIR="" ;; # Linux logs to journald - *) - # Windows users need install.ps1 — they typically only hit this - # path if they ran the script via Git Bash or WSL by mistake. - echo "install.sh: unsupported OS '$(uname -s)' (Windows uses install.ps1)." >&2 - exit 1 - ;; -esac - -# Preflight — must run as root because we write to /usr/local + /var/log. -# node + tar are needed at install time (and node at every hook call). -[[ $EUID -eq 0 ]] || { echo "install.sh: must run as root (use 'sudo')." >&2; exit 1; } -command -v node >/dev/null 2>&1 || { echo "install.sh: 'node' not in PATH (need Node.js >= 20)." >&2; exit 1; } -command -v tar >/dev/null 2>&1 || { echo "install.sh: 'tar' not in PATH." >&2; exit 1; } - -# Stage the payload in a temp dir we clean up on exit. The dir ends up -# with bin/, lib/, VERSION after the tar extracts. -STAGE=$(mktemp -d -t jfrog-mcp-gate.XXXXXX) -trap 'rm -rf "${STAGE}"' EXIT - -if [[ -n "${LOCAL_PACKAGE}" ]]; then - [[ -f "${LOCAL_PACKAGE}" ]] || { echo "install.sh: package not found: ${LOCAL_PACKAGE}" >&2; exit 1; } - echo "==> Installing from local package: ${LOCAL_PACKAGE}" - cp "${LOCAL_PACKAGE}" "${STAGE}/payload.tgz" -else - # Resolve "latest" by fetching the LATEST file (one line of text, the - # version number). Then download mcp-gate-.tgz from that subdir. - echo "==> Resolving latest version from ${URL}/LATEST" - LATEST_VERSION="$(curl -sSfL "${URL}/LATEST" | tr -d '[:space:]')" - [[ -n "${LATEST_VERSION}" ]] || { echo "install.sh: could not resolve version." >&2; exit 1; } - echo "==> Installing jfrog-mcp-gate ${LATEST_VERSION} from ${URL}" - curl -sSfL -o "${STAGE}/payload.tgz" "${URL}/v${LATEST_VERSION}/mcp-gate-${LATEST_VERSION}.tgz" -fi - -tar -xzf "${STAGE}/payload.tgz" -C "${STAGE}" -rm -f "${STAGE}/payload.tgz" - -# Sanity-check the payload before we touch the install root. -[[ -x "${STAGE}/bin/jfrog-mcp-gate.mjs" ]] || { echo "install.sh: payload missing bin/jfrog-mcp-gate.mjs" >&2; exit 1; } -[[ -x "${STAGE}/bin/jfrog-setup-user.mjs" ]] || { echo "install.sh: payload missing bin/jfrog-setup-user.mjs" >&2; exit 1; } -[[ -f "${STAGE}/lib/config.mjs" ]] || { echo "install.sh: payload missing lib/config.mjs" >&2; exit 1; } -[[ -f "${STAGE}/VERSION" ]] || { echo "install.sh: payload missing VERSION" >&2; exit 1; } - -# Lay down the install root — root-owned + 0755 so users can read/run -# but cannot modify without sudo. We blow the dir away first so reinstalls -# don't accumulate stale files. -echo "==> Installing into ${INSTALL_ROOT}" -rm -rf "${INSTALL_ROOT}" -mkdir -p "${INSTALL_ROOT}/bin" "${INSTALL_ROOT}/lib" - -install -o root -g "${GROUP}" -m 0755 "${STAGE}/bin/jfrog-mcp-gate.mjs" "${INSTALL_ROOT}/bin/jfrog-mcp-gate.mjs" -install -o root -g "${GROUP}" -m 0755 "${STAGE}/bin/jfrog-setup-user.mjs" "${INSTALL_ROOT}/bin/jfrog-setup-user.mjs" -install -o root -g "${GROUP}" -m 0644 "${STAGE}/lib/config.mjs" "${INSTALL_ROOT}/lib/config.mjs" -install -o root -g "${GROUP}" -m 0644 "${STAGE}/VERSION" "${INSTALL_ROOT}/VERSION" - -# Audit log — `touch` creates the file if missing (and is a no-op when -# it exists, so reinstalls preserve existing audit lines). chmod 0666 -# lets the user-mode hook + setup-user both append. -touch "${AUDIT_LOG}" -chown "root:${GROUP}" "${AUDIT_LOG}" -chmod 0666 "${AUDIT_LOG}" - -# Per-tick log directory (macOS only — Linux logs to journald). -# 0777 because we don't know yet which user the LaunchAgent will run as. -if [[ -n "${SETUP_LOG_DIR}" ]]; then - mkdir -p "${SETUP_LOG_DIR}" - chmod 0777 "${SETUP_LOG_DIR}" -fi - -# Platform-specific service registration -if [[ "${PLATFORM}" == "macos" ]]; then - PLIST_DEST="/Library/LaunchAgents/com.jfrog.mcp-user-setup.plist" - echo "==> Installing LaunchAgent at ${PLIST_DEST}" - - # LaunchAgent plist — describes a per-user background service to launchd. - # RunAtLoad = run once when the agent loads (i.e. at every login) - # StartInterval = re-run every N seconds (here: 60s, our heal-on-tick) - # ProgramArgs = the command line to execute - # PATH override = launchd's default PATH doesn't include /opt/homebrew - # /usr/local/bin where most users have `node` - cat > "${PLIST_DEST}" < - - - - Label - com.jfrog.mcp-user-setup - ProgramArguments - - /usr/bin/env - node - ${INSTALL_ROOT}/bin/jfrog-setup-user.mjs - - RunAtLoad - - StartInterval - 60 - StandardOutPath - ${SETUP_LOG_DIR}/setup.stdout.log - StandardErrorPath - ${SETUP_LOG_DIR}/setup.stderr.log - EnvironmentVariables - - PATH - /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin - - - -EOF - chown root:wheel "${PLIST_DEST}" - chmod 0644 "${PLIST_DEST}" - - # Kick the LaunchAgent into the currently-logged-in GUI session. - # bootout = unload any older instance with the same Label - # bootstrap = load the (possibly new) plist into the user session - # kickstart = force one immediate tick (-k = re-run even if it just ran) - # /dev/console belongs to whoever owns the active GUI session. - LOGGED_IN_UID=$(stat -f%u /dev/console 2>/dev/null || echo "") - LOGGED_IN_USER=$(stat -f%Su /dev/console 2>/dev/null || echo "") - if [[ -n "${LOGGED_IN_UID}" && "${LOGGED_IN_UID}" != "0" ]]; then - echo "==> Bootstrapping LaunchAgent into uid=${LOGGED_IN_UID} (${LOGGED_IN_USER})" - launchctl bootout "gui/${LOGGED_IN_UID}/com.jfrog.mcp-user-setup" 2>/dev/null || true - launchctl bootstrap "gui/${LOGGED_IN_UID}" "${PLIST_DEST}" - launchctl kickstart -k "gui/${LOGGED_IN_UID}/com.jfrog.mcp-user-setup" - fi - -else - # Linux: systemd --user service + timer. They live in /etc/systemd/user/ - # so every user's `systemctl --user` session picks them up automatically. - # Logs go to journald — view with: journalctl --user -u jfrog-mcp-user-setup.service - SYSTEMD_DIR="/etc/systemd/user" - SERVICE_UNIT="${SYSTEMD_DIR}/jfrog-mcp-user-setup.service" - TIMER_UNIT="${SYSTEMD_DIR}/jfrog-mcp-user-setup.timer" - - echo "==> Installing systemd --user units in ${SYSTEMD_DIR}" - mkdir -p "${SYSTEMD_DIR}" - - # The .service runs once on each timer fire. - cat > "${SERVICE_UNIT}" < "${TIMER_UNIT}" </dev/null 2>&1 || true - - # Best-effort: kick the timer in the currently-logged-in user's session - # so they don't have to log out/in. systemd --user needs that user's - # XDG_RUNTIME_DIR which sudo doesn't carry — so we point at it explicitly. - LOGGED_IN_USER=$(logname 2>/dev/null || echo "") - LOGGED_IN_UID=$(id -u "${LOGGED_IN_USER}" 2>/dev/null || echo "") - if [[ -n "${LOGGED_IN_UID}" && "${LOGGED_IN_UID}" != "0" ]]; then - echo "==> Starting timer in uid=${LOGGED_IN_UID} (${LOGGED_IN_USER}) session" - sudo -u "${LOGGED_IN_USER}" \ - XDG_RUNTIME_DIR="/run/user/${LOGGED_IN_UID}" \ - systemctl --user daemon-reload >/dev/null 2>&1 || true - sudo -u "${LOGGED_IN_USER}" \ - XDG_RUNTIME_DIR="/run/user/${LOGGED_IN_UID}" \ - systemctl --user enable --now jfrog-mcp-user-setup.timer >/dev/null 2>&1 || true - fi -fi - -# Done — print a "what next" hint - -INSTALLED_VERSION="$(cat "${INSTALL_ROOT}/VERSION")" -cat < Installed jfrog-mcp-gate ${INSTALLED_VERSION} (${PLATFORM}). - -Per-user state will appear on the next service tick (<=60s): - ~/.jfrog/mcp-gate/vscode-hooks.json - chat.hookFilesLocations entry in VS Code user settings - -Next: - 1. Push the VS Code enterprise ChatHooks=true policy via your MDM. - macOS: com.jfrog.mcp-gate.mobileconfig - Linux: write /etc/vscode/policy.json with {"ChatHooks": true} - 2. Restart VS Code. - 3. tail -f ${AUDIT_LOG} - -Uninstall: - sudo bash -c "\$(curl -sSfL ${URL}/uninstall.sh)" -EOF diff --git a/mcp-gate/lib/config.mjs b/mcp-gate/lib/config.mjs deleted file mode 100644 index 4b2c27c..0000000 --- a/mcp-gate/lib/config.mjs +++ /dev/null @@ -1,72 +0,0 @@ -import { appendFileSync, mkdirSync } from "node:fs"; -import { homedir, platform } from "node:os"; -import { dirname, join } from "node:path"; - -// Product identity -export const PRODUCT_NAME = "jfrog-mcp-gate"; - - -// Policy — the launch command we require. Anything else is DENIED. -// Example mcp.json entry that PASSES this policy: -// "command": "npx", -// "args": ["--yes", "--registry", "", "@jfrog/agent-guard", "chrome-devtools-mcp@latest"] -export const POLICY = { - command: "npx", - required_args: ["--yes", "@jfrog/agent-guard"], - registry_arg: "--registry", -}; - - -// Where the JSON-per-line audit log lives. -export const AUDIT_LOG_PATH = platform() === "win32" - ? join(process.env.ProgramData ?? "C:\\ProgramData", "JFrog\\Logs\\jfrog-mcp-gate.log") - : "/var/log/jfrog-mcp-gate.log"; - -// VS Code's user-level config folder. -export const VSCODE_USER_DIR = (() => { - const home = homedir(); - if (platform() === "darwin") return join(home, "Library/Application Support/Code/User"); - if (platform() === "win32") return join(process.env.APPDATA ?? home, "Code/User"); - return join(home, ".config/Code/User"); -})(); - -export const VSCODE_SETTINGS_PATH = join(VSCODE_USER_DIR, "settings.json"); - -// Where VS Code looks for our hook config. -export const HOOK_CONFIG_TILDE = "~/.jfrog/mcp-gate/vscode-hooks.json"; - -// Hook-config payload — the JSON that setup-user writes to ~/.jfrog/mcp-gate/vscode-hooks.json. VS Code reads this file -// and spawns `command` for every PreToolUse event. -export const buildHookConfig = (hookBinAbsPath) => ({ - version: 1, - hooks: { - PreToolUse: [{ type: "command", command: hookBinAbsPath }], - }, -}); - - -// JSONC helpers -// VS Code's settings.json + mcp.json allow comments and trailing commas that plain JSON.parse rejects. -// stripJsonc removes them. -export const stripJsonc = (s) => - s - .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, comment) => (comment ? "" : m)) - .replace(/,(\s*[}\]])/g, "$1"); - -export const parseJsonc = (s) => JSON.parse(stripJsonc(s)); - - -// Audit logger — append one JSON entry per line. Never throws. -// Example deny line: -// {"ts":"2026-...","product":"jfrog-mcp-gate","event_type":"decision", -// "tool_name":"mcp_x_y","server":"","decision":"deny", -// "reason":"server not found in mcp.json"} -export 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 */ } -}; diff --git a/mcp-gate/poc/release.sh b/mcp-gate/poc/release.sh deleted file mode 100755 index 74e0997..0000000 --- a/mcp-gate/poc/release.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash -# Engineer-local release fallback. Canonical release is the GH workflow; -# this exists so we can ship updates before CI infra onboards the repo. -# Usage: ./poc/release.sh [--dry-run] -# Env: JFROG_MCP_GATE_REPO (default: jfrog-cli-plugins/jfrog-mcp-gate) -# Needs: jf (JFrog CLI) logged in, tar. - -set -euo pipefail - -# Settings -DRY_RUN=0 -[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 - -MCP_GATE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # the mcp-gate/ folder -VERSION="$(cat "${MCP_GATE_ROOT}/VERSION")" -TGZ="mcp-gate-${VERSION}.tgz" -REPO="${JFROG_MCP_GATE_REPO:-jfrog-cli-plugins/jfrog-mcp-gate}" -TOP_LEVEL_FILES=(install.sh uninstall.sh install.ps1 uninstall.ps1 com.jfrog.mcp-gate.mobileconfig) - -# Preflight (need VERSION + tar; need jf if not dry-run) - -[[ -n "${VERSION}" ]] || { echo "release.sh: VERSION is empty." >&2; exit 1; } -command -v tar >/dev/null 2>&1 || { echo "release.sh: tar 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 - -# Build the install package (.tgz) + LATEST file -DIST="${MCP_GATE_ROOT}/dist" -mkdir -p "${DIST}" -rm -f "${DIST}/${TGZ}" "${DIST}/LATEST" - -echo "==> Packaging ${TGZ} (version=${VERSION})" -tar -C "${MCP_GATE_ROOT}" -czf "${DIST}/${TGZ}" bin lib VERSION -echo "${VERSION}" > "${DIST}/LATEST" - -echo " -> ${DIST}/${TGZ}" -echo " -> ${DIST}/LATEST" - -# Upload to Artifactory (or print what would have been uploaded) -if [[ ${DRY_RUN} -eq 1 ]]; then - echo - echo "==> Dry run, skipping upload." - echo " Would upload to ${REPO}:" - echo " v${VERSION}/${TGZ}" - echo " LATEST" - for f in "${TOP_LEVEL_FILES[@]}"; do echo " ${f}"; done - exit 0 -fi - -echo -echo "==> Uploading versioned package -> ${REPO}/v${VERSION}/" -jf rt upload "${DIST}/${TGZ}" "${REPO}/v${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 "${MCP_GATE_ROOT}/${f}" "${REPO}/${f}" -done - -cat < Release ${VERSION} published to ${REPO}. - IT command: - curl -sSfL https://\${ARTIFACTORY_HOST}/artifactory/${REPO}/install.sh | sudo bash -EOF diff --git a/mcp-gate/uninstall.ps1 b/mcp-gate/uninstall.ps1 deleted file mode 100644 index 50133fe..0000000 --- a/mcp-gate/uninstall.ps1 +++ /dev/null @@ -1,81 +0,0 @@ -# jfrog-mcp-gate uninstaller (Windows). macOS / Linux users: uninstall.sh. -# Removes everything install.ps1 wrote plus the per-user state for the -# currently-logged-in user. The audit log is preserved for forensics. -#Requires -RunAsAdministrator - -$ErrorActionPreference = "Stop" - -$InstallRoot = Join-Path $env:ProgramFiles "JFrog\mcp-gate" -$AuditLog = Join-Path $env:ProgramData "JFrog\Logs\jfrog-mcp-gate.log" -$TaskName = "JFrogMcpUserSetup" - -# Stop the Scheduled Task. We do this BEFORE removing files so the task -# can't fire one last time mid-uninstall and recreate state. -if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { - Write-Host "==> Unregistering Scheduled Task $TaskName" - Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -} - -# Per-user state cleanup (BEFORE deleting the install root, because we -# call setup-user.mjs --clean from it). -# -# This script runs as Administrator but the user data we want to delete -# lives in another user's profile (Documents, AppData, .jfrog\). So: -# 1. Find who's interactively logged in (Win32_ComputerSystem.UserName -# gives "DOMAIN\username"). -# 2. Find that user's profile folder via Win32_UserProfile.LocalPath. -# 3. Set JFROG_MCP_GATE_HOME so setup-user.mjs writes to THEIR home -# instead of the Administrator's. -$activeUser = (Get-CimInstance -ClassName Win32_ComputerSystem).UserName -if ($activeUser) { - $userOnly = $activeUser.Split("\")[-1] # "DOMAIN\foo" -> "foo" - $userHome = (Get-CimInstance -ClassName Win32_UserProfile | - Where-Object { $_.LocalPath -like "*\$userOnly" } | - Select-Object -First 1).LocalPath - - if ($userHome) { - $McpGateDir = Join-Path $userHome ".jfrog\mcp-gate" - $HookConfig = Join-Path $McpGateDir "vscode-hooks.json" - $setupBin = Join-Path $InstallRoot "bin\jfrog-setup-user.mjs" - - if (Test-Path $setupBin) { - Write-Host "==> Stripping chat.hookFilesLocations entry for $userOnly" - # Temporarily set JFROG_MCP_GATE_HOME so setup-user.mjs - # operates on the active user's settings.json, not ours. - $env:JFROG_MCP_GATE_HOME = $userHome - try { & node $setupBin --clean } catch { } - Remove-Item Env:JFROG_MCP_GATE_HOME -ErrorAction SilentlyContinue - } - - if (Test-Path $HookConfig) { - Write-Host "==> Removing $HookConfig" - Remove-Item $HookConfig -Force - } - # Remove the parent dir only if it became empty (-Force without - # -Recurse on a folder fails if not empty — that's the behavior - # we want). - if (Test-Path $McpGateDir) { - try { Remove-Item $McpGateDir -Force } catch { } - } - } -} - -# Install root + (if empty) the JFrog parent folder. -if (Test-Path $InstallRoot) { - Write-Host "==> Removing $InstallRoot" - Remove-Item -Recurse -Force $InstallRoot -} -$parent = Split-Path $InstallRoot -Parent -if ((Test-Path $parent) -and -not (Get-ChildItem $parent -ErrorAction SilentlyContinue)) { - Remove-Item $parent -} - -# Done — print a "what's preserved" hint -Write-Host "" -Write-Host "==> Uninstall complete." -Write-Host "" -Write-Host "Preserved for forensics:" -Write-Host " $AuditLog" -Write-Host "" -Write-Host "To remove the ChatHooks=1 enterprise policy:" -Write-Host " reg delete HKLM\Software\Policies\Microsoft\VSCode /v ChatHooks /f" diff --git a/mcp-gate/uninstall.sh b/mcp-gate/uninstall.sh deleted file mode 100755 index 4be42f5..0000000 --- a/mcp-gate/uninstall.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash -# jfrog-mcp-gate uninstaller (macOS + Linux). Windows users: uninstall.ps1. -# Removes everything install.sh wrote plus the per-user state for the -# currently-logged-in user. The audit log is preserved for forensics. -set -euo pipefail - -INSTALL_ROOT="/usr/local/jfrog/mcp-gate" -AUDIT_LOG="/var/log/jfrog-mcp-gate.log" - -# OS dispatch + active-user lookup. -# We capture both the username and the uid because launchctl/systemctl -# both need the uid to address the right user session. -# macOS: stat /dev/console — that device is owned by whoever owns the -# active GUI session. -# Linux: logname — the name of the user who started the login session -# we inherited from sudo. -case "$(uname -s)" in - Darwin) - PLATFORM=macos - LOGGED_IN_USER=$(stat -f%Su /dev/console 2>/dev/null || echo "") - LOGGED_IN_UID=$(stat -f%u /dev/console 2>/dev/null || echo "") - PLIST_DEST="/Library/LaunchAgents/com.jfrog.mcp-user-setup.plist" - SETUP_LOG_HINT="/Library/Logs/jfrog-mcp-gate/setup.{stdout,stderr}.log" - ;; - Linux) - PLATFORM=linux - LOGGED_IN_USER=$(logname 2>/dev/null || echo "") - LOGGED_IN_UID=$(id -u "${LOGGED_IN_USER}" 2>/dev/null || echo "") - SYSTEMD_DIR="/etc/systemd/user" - SETUP_LOG_HINT="journalctl --user -u jfrog-mcp-user-setup.service (until cleared)" - ;; - *) - echo "uninstall.sh: unsupported OS '$(uname -s)' (Windows uses uninstall.ps1)." >&2 - exit 1 - ;; -esac - -[[ $EUID -eq 0 ]] || { echo "uninstall.sh: must run as root." >&2; exit 1; } - -# Stop the per-user service. We do this BEFORE removing files so the -# scheduler can't fire one last tick mid-uninstall and recreate state. -if [[ "${PLATFORM}" == "macos" ]]; then - # bootout = the opposite of bootstrap — unload the LaunchAgent from - # the active GUI session. - if [[ -n "${LOGGED_IN_UID}" && "${LOGGED_IN_UID}" != "0" ]]; then - echo "==> Booting out LaunchAgent from uid=${LOGGED_IN_UID}" - launchctl bootout "gui/${LOGGED_IN_UID}/com.jfrog.mcp-user-setup" 2>/dev/null || true - fi -else - # disable --now = stop the timer right now AND unenable it for future - # logins. Reach across the sudo boundary by setting XDG_RUNTIME_DIR. - if [[ -n "${LOGGED_IN_UID}" && "${LOGGED_IN_UID}" != "0" ]]; then - echo "==> Stopping timer in uid=${LOGGED_IN_UID} (${LOGGED_IN_USER}) session" - sudo -u "${LOGGED_IN_USER}" \ - XDG_RUNTIME_DIR="/run/user/${LOGGED_IN_UID}" \ - systemctl --user disable --now jfrog-mcp-user-setup.timer >/dev/null 2>&1 || true - fi - # Also remove the system-wide --global enablement. - systemctl --global disable jfrog-mcp-user-setup.timer >/dev/null 2>&1 || true -fi - -# Per-user state cleanup (BEFORE deleting the install root, because we -# call setup-user.mjs --clean from it). We delegate settings.json -# cleanup to that script so the JSONC logic stays in one place. Runs -# AS the active user so file ownership stays correct. -if [[ -n "${LOGGED_IN_USER}" && "${LOGGED_IN_USER}" != "root" ]]; then - # eval echo expands ~user into the user's $HOME on all shells. - USER_HOME=$(eval echo "~${LOGGED_IN_USER}") - MCP_GATE_DIR="${USER_HOME}/.jfrog/mcp-gate" - HOOK_CONFIG="${MCP_GATE_DIR}/vscode-hooks.json" - SETUP_USER="${INSTALL_ROOT}/bin/jfrog-setup-user.mjs" - - if [[ -x "${SETUP_USER}" ]]; then - echo "==> Stripping chat.hookFilesLocations entry for ${LOGGED_IN_USER}" - sudo -u "${LOGGED_IN_USER}" node "${SETUP_USER}" --clean || true - fi - - [[ -f "${HOOK_CONFIG}" ]] && { echo "==> Removing ${HOOK_CONFIG}"; rm -f "${HOOK_CONFIG}"; } - # rmdir only succeeds if the dir is empty — exactly what we want here. - [[ -d "${MCP_GATE_DIR}" ]] && rmdir "${MCP_GATE_DIR}" 2>/dev/null || true -fi - -# Service files (plist on macOS / unit files on Linux) + install root. -if [[ "${PLATFORM}" == "macos" ]]; then - [[ -f "${PLIST_DEST}" ]] && { echo "==> Removing ${PLIST_DEST}"; rm -f "${PLIST_DEST}"; } -else - for unit in jfrog-mcp-user-setup.timer jfrog-mcp-user-setup.service; do - [[ -f "${SYSTEMD_DIR}/${unit}" ]] && { echo "==> Removing ${SYSTEMD_DIR}/${unit}"; rm -f "${SYSTEMD_DIR}/${unit}"; } - done -fi - -if [[ -d "${INSTALL_ROOT}" ]]; then - echo "==> Removing ${INSTALL_ROOT}" - rm -rf "${INSTALL_ROOT}" -fi -# Clean up the /usr/local/jfrog/ parent if nothing else is in it. -[[ -d "/usr/local/jfrog" && -z "$(ls -A /usr/local/jfrog)" ]] && rmdir /usr/local/jfrog - -# Done — print a "what's preserved" hint -cat < Uninstall complete. - -Preserved for forensics: - ${AUDIT_LOG} - ${SETUP_LOG_HINT} - -To remove the ChatHooks=true enterprise policy: - macOS: sudo profiles remove -identifier com.jfrog.mcp-gate - Linux: remove /etc/vscode/policy.json -EOF From 36520249bad738f85b08dc2ade4795a6c35b4123 Mon Sep 17 00:00:00 2001 From: yanivt Date: Wed, 27 May 2026 12:39:39 +0300 Subject: [PATCH 3/7] change workflow --- .github/workflows/agent-guard-hook-ci.yml | 42 ++++++++++++++--------- agent-guard-hook/README.md | 17 +++++---- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/.github/workflows/agent-guard-hook-ci.yml b/.github/workflows/agent-guard-hook-ci.yml index caf7fdd..e4b6ea5 100644 --- a/.github/workflows/agent-guard-hook-ci.yml +++ b/.github/workflows/agent-guard-hook-ci.yml @@ -1,18 +1,23 @@ # agent-guard-hook CI Workflow -# One workflow for dev + release. -# - push to any branch → auto-detected: master/main = release, others = dev -# - pull_request targeting main → always a dev build against the PR's source -# branch. Exercises pre-build → build → post-build -# end-to-end so a merge can't silently break CI. -# distribution + promote-latest are skipped -# (release-only gate). -# - workflow_dispatch → respects the `build-type` input +# Three triggers, three gate levels — modeled after mcp-proxy's flow: +# - pull_request → validation only. Runs pre-build + build-and-upload's +# local steps (sed-inject + tar) so a broken build fails +# the PR check. NO Artifactory upload, NO post-build. +# - push (master) → "dev" upload. Pushes the merged commit's archive into +# the internal dev Artifactory repo. NOT distributed to +# releases.jfrog.io. Lets us soak-test internally before +# cutting a release. +# - workflow_dispatch with build-type=release → full release flow. Uploads +# to release repo, creates Release Bundle, promotes, +# mirrors to releases.jfrog.io, updates latest/. +# Run this manually when the dev build has been verified. name: agent-guard-hook CI on: push: + branches: [master, main] # feature-branch pushes do nothing — only merges trigger dev upload paths: - "agent-guard-hook/**" - ".github/workflows/agent-guard-hook-ci.yml" @@ -24,15 +29,13 @@ on: workflow_dispatch: inputs: build-type: - description: 'Override build type (auto=detect from branch, milestone=create milestone build from any branch)' + description: 'dev (default) = internal Artifactory only. release = full publish to releases.jfrog.io.' required: false type: choice options: - - auto - dev - release - - milestone - default: 'auto' + default: 'dev' concurrency: group: agent-guard-hook-${{ github.ref }} @@ -85,8 +88,11 @@ jobs: project: jfml service-name: agent-guard-hook short-service-name: aghook # used only on preRelease/aghook-* branches - build-type: ${{ inputs.build-type || 'auto' }} # auto → master/main = release, others = dev - create-release-candidate-tag: 'true' # push an RC git tag like agent-guard-hook/v0.1.1-rc1 + # 'release' only when explicitly requested via workflow_dispatch. + # Everything else (PR validation, push to master) 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 }} @@ -154,7 +160,9 @@ jobs: 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: | @@ -181,6 +189,7 @@ jobs: # 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: | @@ -189,13 +198,14 @@ jobs: # 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 / milestone builds), promotes the bundle to that environment. + # 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: devf-dind-amd-scale-set timeout-minutes: 20 outputs: @@ -218,7 +228,7 @@ jobs: 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 (milestone has distribute_to_edges=false). + # 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 diff --git a/agent-guard-hook/README.md b/agent-guard-hook/README.md index 1426f5c..6bdba21 100644 --- a/agent-guard-hook/README.md +++ b/agent-guard-hook/README.md @@ -102,19 +102,22 @@ One workflow: `.github/workflows/agent-guard-hook-ci.yml`. | Trigger | What it does | | --- | --- | -| push to `master`/`main` (touching `agent-guard-hook/**`) | Release build — versioned archive uploaded, release bundle created and promoted, mirrored to releases.jfrog.io, copied into `latest/`. | -| push to any other branch | Dev build — published to the dev repo. Not distributed. | -| pull_request → `master`/`main` | Dev build only — exercises pre-build → build → post-build end-to-end. Distribution + promote-latest skipped. | -| workflow_dispatch | Same flow, but `build-type` can be overridden (`dev` / `release` / `milestone`). | +| pull_request → `master`/`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 `master`/`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 master/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 `master`. The push triggers the workflow with `build-type: auto`, which detects `release` from the branch. 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. -2. Verify the run in GitHub Actions; the `promote-latest` job is the last step. -3. `install.mjs` now resolves the new version through the `LATEST` file. +1. Merge the change to `master`. CI fires a dev build automatically; the archive lands in the internal dev Artifactory repo 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) From 528a8119d034c61cf1fb5850023938f6d2ca1059 Mon Sep 17 00:00:00 2001 From: yanivt Date: Wed, 27 May 2026 13:02:59 +0300 Subject: [PATCH 4/7] fix workflow + readme --- .github/workflows/agent-guard-hook-ci.yml | 26 ++++++++--------------- agent-guard-hook/README.md | 8 +++---- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/.github/workflows/agent-guard-hook-ci.yml b/.github/workflows/agent-guard-hook-ci.yml index e4b6ea5..e462c4d 100644 --- a/.github/workflows/agent-guard-hook-ci.yml +++ b/.github/workflows/agent-guard-hook-ci.yml @@ -1,28 +1,21 @@ # agent-guard-hook CI Workflow -# Three triggers, three gate levels — modeled after mcp-proxy's flow: # - pull_request → validation only. Runs pre-build + build-and-upload's -# local steps (sed-inject + tar) so a broken build fails -# the PR check. NO Artifactory upload, NO post-build. -# - push (master) → "dev" upload. Pushes the merged commit's archive into -# the internal dev Artifactory repo. NOT distributed to -# releases.jfrog.io. Lets us soak-test internally before -# cutting a release. -# - workflow_dispatch with build-type=release → full release flow. Uploads -# to release repo, creates Release Bundle, promotes, -# mirrors to releases.jfrog.io, updates latest/. -# Run this manually when the dev build has been verified. +# local steps (sed-inject + tar). NO Artifactory upload, NO post-build. +# - push (main) → "dev" upload. Pushes the merged commit's archive into entplus NOT distributed to releases.jfrog.io. +# - workflow_dispatch with build-type=release → full release flow. Uploads to release repo, creates Release Bundle, promotes, +# mirrors to releases.jfrog.io, updates latest/. Run this manually when the dev build has been verified. name: agent-guard-hook CI on: push: - branches: [master, main] # feature-branch pushes do nothing — only merges trigger dev upload + branches: [main] paths: - "agent-guard-hook/**" - ".github/workflows/agent-guard-hook-ci.yml" pull_request: - branches: [master, main] + branches: [main] paths: - "agent-guard-hook/**" - ".github/workflows/agent-guard-hook-ci.yml" @@ -65,7 +58,7 @@ jobs: # "promotion_stage": "release", // empty for dev builds # "repositories": { # "generic": { - # "deploy": "coding-agents-generic-dev-master-local" // or release repo + # "deploy": "dev-main-generic-local" // or "release-generic-local" on release builds # } # } # } @@ -89,7 +82,7 @@ jobs: 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 master) is a dev build. + # 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' }} @@ -100,8 +93,7 @@ jobs: jfrog-webhook-creds: ${{ secrets.JFDEV_WEBHOOK_CREDS }} generic: 'true' # Single-file ".mjs" packaged as a generic .tgz. - # Phase 5(a) — Custom build: sed the metadata.version into line 2 of the - # .mjs, tar, upload to the deploy repo, publish build-info. + # 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 diff --git a/agent-guard-hook/README.md b/agent-guard-hook/README.md index 6bdba21..29fa0a2 100644 --- a/agent-guard-hook/README.md +++ b/agent-guard-hook/README.md @@ -102,10 +102,10 @@ One workflow: `.github/workflows/agent-guard-hook-ci.yml`. | Trigger | What it does | | --- | --- | -| pull_request → `master`/`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 `master`/`main` (after merge) | Dev build — versioned archive uploaded to the internal dev Artifactory repo for soak testing. **Not distributed to `releases.jfrog.io`.** | +| 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 master/main pushes and pull_requests only. | +| 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). @@ -113,7 +113,7 @@ On PR runs everything after `build-and-upload`'s local steps is skipped. ### Cutting a release -1. Merge the change to `master`. CI fires a dev build automatically; the archive lands in the internal dev Artifactory repo with a version like `0.1.1-devf-…`. +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. From fae8ffce5081acd5720c53602399912999d9e7bd Mon Sep 17 00:00:00 2001 From: yanivt Date: Wed, 27 May 2026 14:54:26 +0300 Subject: [PATCH 5/7] remove uninstall --- .github/workflows/agent-guard-hook-ci.yml | 2 +- agent-guard-hook/.jfrog-distribution.yml | 4 +- agent-guard-hook/agent-guard-hook.mjs | 27 +------- .../com.jfrog.agent-guard-hook.mobileconfig | 4 -- agent-guard-hook/install.mjs | 68 +------------------ 5 files changed, 5 insertions(+), 100 deletions(-) diff --git a/.github/workflows/agent-guard-hook-ci.yml b/.github/workflows/agent-guard-hook-ci.yml index e462c4d..aca1b45 100644 --- a/.github/workflows/agent-guard-hook-ci.yml +++ b/.github/workflows/agent-guard-hook-ci.yml @@ -51,7 +51,7 @@ jobs: # metadata example for pre-build step # { # "service_name": "agent-guard-hook", - # "version": "0.1.1", // ← computed from existing git tags + # "version": "0.1.1", // computed from existing git tags # "build_type": "release", # "build_number": "20260527…", # "rc_tag": "agent-guard-hook/v0.1.1-rc1", diff --git a/agent-guard-hook/.jfrog-distribution.yml b/agent-guard-hook/.jfrog-distribution.yml index 47c4680..49b421f 100644 --- a/agent-guard-hook/.jfrog-distribution.yml +++ b/agent-guard-hook/.jfrog-distribution.yml @@ -4,13 +4,13 @@ # is interpolated by next-gen-ci-distribution at runtime. artifacts: - # Versioned archive (immutable per version). + # Versioned archive. - type: generic path: agent-guard-hook//agent-guard-hook-.tgz target: repository: coding-agents-generic - # Top-level files (rewritten on every release — what IT downloads). + # Top-level files - type: generic path: agent-guard-hook/install.mjs target: diff --git a/agent-guard-hook/agent-guard-hook.mjs b/agent-guard-hook/agent-guard-hook.mjs index 02ae751..02cdb8b 100644 --- a/agent-guard-hook/agent-guard-hook.mjs +++ b/agent-guard-hook/agent-guard-hook.mjs @@ -8,7 +8,6 @@ // Modes: // hook mode: allow / deny one tool call. // --register this hook in VS Code settings.json. -// --unregister it (used by uninstall). // --version print the version marker on line 2. import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; @@ -181,9 +180,6 @@ const findServerForTool = (toolName, serverNames) => { }; -// Require the --registry value parses as an http(s) URL. We don't whitelist -// hostnames — on-prem and customer-owned Artifactory subdomains are legit — -// but rejecting non-URL strings catches typos and obviously bogus values. const isHttpUrl = (value) => { try { const parsed = new URL(value); @@ -267,7 +263,7 @@ const hookMode = async () => { }; -// ────────────────────────── --register / --unregister ────────────────────────── +// ────────────────────────── --register ────────────────────────── const SETTINGS_INDENT = 2; @@ -290,16 +286,6 @@ const withHookEntry = (current) => { return next; }; -const withoutHookEntry = (current) => { - const next = { ...current }; - const existing = next["chat.hookFilesLocations"]; - const locations = existing && typeof existing === "object" ? { ...existing } : {}; - delete locations[HOOK_CONFIG_TILDE]; - if (Object.keys(locations).length === 0) delete next["chat.hookFilesLocations"]; - else 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"; @@ -343,21 +329,10 @@ const register = () => { process.stdout.write(`${PRODUCT_NAME}: registered in ${VSCODE_SETTINGS_PATH}\n`); }; -const unregister = () => { - if (!isHookRegistered()) { - process.stdout.write(`${PRODUCT_NAME}: not registered, nothing to remove\n`); - return; - } - updateSettings(withoutHookEntry); - process.stdout.write(`${PRODUCT_NAME}: unregistered\n`); -}; - - // ────────────────────────── entrypoint ────────────────────────── const arg = process.argv[2]; if (arg === "--register") register(); -else if (arg === "--unregister") unregister(); else if (arg === "--version") process.stdout.write(readVersion() + "\n"); else { hookMode().catch((err) => { diff --git a/agent-guard-hook/com.jfrog.agent-guard-hook.mobileconfig b/agent-guard-hook/com.jfrog.agent-guard-hook.mobileconfig index ea8aad8..ab5fe6e 100644 --- a/agent-guard-hook/com.jfrog.agent-guard-hook.mobileconfig +++ b/agent-guard-hook/com.jfrog.agent-guard-hook.mobileconfig @@ -2,10 +2,6 @@ diff --git a/agent-guard-hook/install.mjs b/agent-guard-hook/install.mjs index 35867e2..4ef6834 100644 --- a/agent-guard-hook/install.mjs +++ b/agent-guard-hook/install.mjs @@ -8,22 +8,13 @@ import { join } from "node:path"; const PRODUCT_NAME = "agent-guard-hook"; const HOOK_FILE = `${PRODUCT_NAME}.mjs`; -const HOOK_CONFIG_TILDE = `~/.vscode/hooks/${PRODUCT_NAME}.json`; 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 HOOK_CONFIG = join(HOOK_DIR, `${PRODUCT_NAME}.json`); const AUDIT_LOG = join(HOOK_DIR, `${PRODUCT_NAME}.log`); -// VS Code's user-level settings.json (kept in sync with agent-guard-hook.mjs). -const VSCODE_SETTINGS_PATH = (() => { - if (platform() === "darwin") return join(HOME, "Library/Application Support/Code/User/settings.json"); - if (platform() === "win32") return join(process.env.APPDATA ?? join(HOME, "AppData/Roaming"), "Code/User/settings.json"); - return join(HOME, ".config/Code/User/settings.json"); -})(); - const ART_HOST = "https://releases.jfrog.io/artifactory"; const REPO = "coding-agents-generic"; @@ -140,63 +131,6 @@ const cmdInstall = async () => { }; -// ────────────────────────── uninstall ────────────────────────── - -// Fallback for when the hook script was already deleted by hand — strip our -// key from settings.json directly so we don't leave a dangling entry behind. -const stripSettingsEntry = () => { - if (!existsSync(VSCODE_SETTINGS_PATH)) return; - let text; - try { text = readFileSync(VSCODE_SETTINGS_PATH, "utf8"); } - catch { return; } - // VS Code allows JSONC; mimic agent-guard-hook.mjs's stripper. - const stripped = text - .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, c) => (c ? "" : m)) - .replace(/,(\s*[}\]])/g, "$1"); - let parsed; - try { parsed = JSON.parse(stripped); } catch { return; } - const locations = parsed?.["chat.hookFilesLocations"]; - if (!locations || typeof locations !== "object" || !(HOOK_CONFIG_TILDE in locations)) return; - delete locations[HOOK_CONFIG_TILDE]; - if (Object.keys(locations).length === 0) delete parsed["chat.hookFilesLocations"]; - const tmp = `${VSCODE_SETTINGS_PATH}.${process.pid}.tmp`; - writeFileSync(tmp, JSON.stringify(parsed, null, 2) + "\n", "utf8"); - renameSync(tmp, VSCODE_SETTINGS_PATH); - log(`stripped settings.json entry at ${VSCODE_SETTINGS_PATH}`); -}; - - -const cmdUninstall = () => { - log(`uninstalling ${PRODUCT_NAME}`); - - if (existsSync(HOOK_SCRIPT)) { - spawnSync(process.execPath, [HOOK_SCRIPT, "--unregister"], { stdio: "inherit" }); - } else { - log("hook script not present, cleaning settings.json directly"); - stripSettingsEntry(); - } - - // Archive the audit log rather than delete it — forensics for IT. - if (existsSync(AUDIT_LOG)) { - const stamp = new Date().toISOString().replace(/[:.]/g, "-"); - const archivedLog = `${AUDIT_LOG}.uninstalled-${stamp}`; - renameSync(AUDIT_LOG, archivedLog); - log(`archived audit log → ${archivedLog}`); - } - - for (const path of [HOOK_SCRIPT, HOOK_CONFIG]) { - if (existsSync(path)) { - rmSync(path); - log(`removed ${path}`); - } - } - log("done"); -}; - - // ────────────────────────── entrypoint ────────────────────────── -(async () => { - if (flag("--uninstall")) cmdUninstall(); - else await cmdInstall(); -})().catch((err) => exitWithError(err?.stack ?? err?.message ?? String(err))); +cmdInstall().catch((err) => exitWithError(err?.stack ?? err?.message ?? String(err))); From 05bd58dcd283110a9b4650f3755220ef61327d51 Mon Sep 17 00:00:00 2001 From: yanivt Date: Wed, 27 May 2026 16:20:38 +0300 Subject: [PATCH 6/7] change ci --- .github/workflows/agent-guard-hook-ci.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/agent-guard-hook-ci.yml b/.github/workflows/agent-guard-hook-ci.yml index aca1b45..2939927 100644 --- a/.github/workflows/agent-guard-hook-ci.yml +++ b/.github/workflows/agent-guard-hook-ci.yml @@ -1,9 +1,16 @@ # 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. Pushes the merged commit's archive into entplus NOT distributed to releases.jfrog.io. -# - workflow_dispatch with build-type=release → full release flow. Uploads to release repo, creates Release Bundle, promotes, -# mirrors to releases.jfrog.io, updates latest/. Run this manually when the dev build has been verified. +# - 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 @@ -58,7 +65,7 @@ jobs: # "promotion_stage": "release", // empty for dev builds # "repositories": { # "generic": { - # "deploy": "dev-main-generic-local" // or "release-generic-local" on release builds + # "deploy": "dev-master-generic-local" # } # } # } @@ -89,8 +96,6 @@ jobs: # 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 }} - # creates the Artifactory webhook that auto-provisions dev repos for new branches - jfrog-webhook-creds: ${{ secrets.JFDEV_WEBHOOK_CREDS }} 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. From 9e53697988f2a0524a2c07995b396d9e3a52a7a1 Mon Sep 17 00:00:00 2001 From: yanivt Date: Wed, 27 May 2026 16:32:08 +0300 Subject: [PATCH 7/7] change run on to ubuntu --- .github/workflows/agent-guard-hook-ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/agent-guard-hook-ci.yml b/.github/workflows/agent-guard-hook-ci.yml index 2939927..0dceb2e 100644 --- a/.github/workflows/agent-guard-hook-ci.yml +++ b/.github/workflows/agent-guard-hook-ci.yml @@ -71,7 +71,7 @@ jobs: # } pre-build: name: Pre-Build - runs-on: devf-dind-amd-scale-set + runs-on: ubuntu-latest timeout-minutes: 15 outputs: metadata: ${{ steps.pre-build.outputs.metadata }} @@ -102,7 +102,7 @@ jobs: build-and-upload: name: Build & Upload needs: pre-build - runs-on: devf-dind-amd-scale-set + runs-on: ubuntu-latest timeout-minutes: 15 outputs: version: ${{ steps.metadata.outputs.version }} @@ -203,7 +203,7 @@ jobs: name: Post-Build needs: [pre-build, build-and-upload] if: github.event_name != 'pull_request' - runs-on: devf-dind-amd-scale-set + runs-on: ubuntu-latest timeout-minutes: 20 outputs: promotion-successful: ${{ steps.post-build.outputs.promotion-successful }} @@ -234,7 +234,7 @@ jobs: 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: devf-dind-amd-scale-set + runs-on: ubuntu-latest timeout-minutes: 60 steps: - name: Checkout @@ -259,7 +259,7 @@ jobs: 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: devf-dind-amd-scale-set + runs-on: ubuntu-latest timeout-minutes: 10 env: VERSION: ${{ needs.build-and-upload.outputs.version }}