From 5b9202e106c1f4eff7d44442318bef7873b0c169 Mon Sep 17 00:00:00 2001 From: Sarah Sanders <88458517+sarahxsanders@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:42:59 -0400 Subject: [PATCH] feat: add Go framework support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Go as a supported framework following the backend-framework pattern (Rails): - Integration.go enum entry at the end of the frameworks block, before the language fallbacks - GO_AGENT_CONFIG in src/frameworks/go/ — claims a go.mod with a module directive, gathers the go directive version as agent context, installs via go get github.com/posthog/posthog-go - goModulesPackageManager helper + FRAMEWORK_REGISTRY entry - bash-fence grammar for the go binary: get/build/vet/fmt/version/list plus go mod tidy/download/verify/graph/why; run/test/generate stay denied (arbitrary code execution), go mod edit stays denied (rewrites module requirements) - Pins framework: go on integration-go in the variant-resolution contract test, matching context-mill PR #267 which must release before this merges - Marks go.mod as a real framework target in the agentic manifest comment Generated-By: PostHog Code Task-Id: 9c949855-7611-48f2-b8b5-7aa4112543e5 --- src/frameworks/go/go-wizard-agent.ts | 101 ++++++++++++++++++ src/lib/__tests__/wizard-can-use-tool.test.ts | 20 +++- src/lib/agent/bash-fence.ts | 28 ++++- .../__tests__/variant-resolution.test.ts | 2 +- src/lib/constants.ts | 1 + src/lib/detection/__tests__/framework.test.ts | 41 +++++++ .../__tests__/package-manager.test.ts | 2 + src/lib/detection/agentic.ts | 5 +- src/lib/detection/package-manager.ts | 19 ++++ src/lib/registry.ts | 2 + 10 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 src/frameworks/go/go-wizard-agent.ts diff --git a/src/frameworks/go/go-wizard-agent.ts b/src/frameworks/go/go-wizard-agent.ts new file mode 100644 index 000000000..6010e945b --- /dev/null +++ b/src/frameworks/go/go-wizard-agent.ts @@ -0,0 +1,101 @@ +/* Go wizard using posthog-agent with PostHog MCP */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { WizardRunOptions } from '@utils/types'; +import type { FrameworkConfig } from '@lib/framework-config'; +import { goModulesPackageManager } from '@lib/detection/package-manager'; +import { Integration } from '@lib/constants'; + +type GoContext = { + goVersion?: string; +}; + +function readGoMod(installDir: string): string | undefined { + const goModPath = path.join(installDir, 'go.mod'); + if (!fs.existsSync(goModPath)) { + return undefined; + } + return fs.readFileSync(goModPath, 'utf-8'); +} + +/** The `go 1.x` directive from go.mod (the toolchain floor, not a patch version). */ +function getGoVersion(installDir: string): string | undefined { + const goMod = readGoMod(installDir); + return goMod?.match(/^go\s+([\d.]+)/m)?.[1]; +} + +export const GO_AGENT_CONFIG: FrameworkConfig = { + metadata: { + name: 'Go', + integration: Integration.go, + docsUrl: 'https://posthog.com/docs/libraries/go', + gatherContext: (options: WizardRunOptions) => { + const goVersion = getGoVersion(options.installDir); + return Promise.resolve({ goVersion }); + }, + }, + + detection: { + packageName: 'posthog-go', + packageDisplayName: 'Go', + usesPackageJson: false, + getVersion: () => undefined, + // A go.mod with a module directive marks a Go module root; a bare + // directory containing .go files without one is not integratable. + detect: (options) => { + const goMod = readGoMod(options.installDir); + return Promise.resolve(!!goMod && /^module\s+\S+/m.test(goMod)); + }, + detectPackageManager: goModulesPackageManager, + }, + + environment: { + uploadToHosting: false, + getEnvVars: (apiKey: string, host: string) => ({ + POSTHOG_API_KEY: apiKey, + POSTHOG_HOST: host, + }), + }, + + analytics: { + getTags: (context) => ({ + goVersion: context.goVersion || 'unknown', + }), + }, + + prompts: { + projectTypeDetection: + 'This is a Go project. Look for go.mod, go.sum, cmd/, internal/, and main packages to confirm.', + packageInstallation: + 'Install the PostHog Go SDK with `go get github.com/posthog/posthog-go`. Do not manually edit go.mod or go.sum; the go tool updates them automatically. Run `go mod tidy` afterwards if imports change.', + getAdditionalContextLines: (context) => { + const lines = [ + `Framework docs ID: go (use posthog://docs/frameworks/go for documentation)`, + ]; + if (context.goVersion) { + lines.push(`Go version (go.mod directive): ${context.goVersion}`); + } + lines.push( + 'Create one PostHog client per process and close it during graceful shutdown (`defer client.Close()`) so queued events flush.', + ); + return lines; + }, + }, + + ui: { + successMessage: 'PostHog integration complete', + estimatedDurationMinutes: 5, + getOutroChanges: () => [ + 'Analyzed your Go project structure', + 'Installed the posthog-go SDK via go get', + 'Initialized a shared PostHog client configured from environment variables', + 'Instrumented meaningful server events with client.Enqueue(posthog.Capture{...})', + ], + getOutroNextSteps: () => [ + 'Run your Go service and trigger the instrumented code paths', + 'Visit your PostHog dashboard to see incoming events', + 'Use client.Enqueue(posthog.Capture{...}) to track custom events', + 'Keep client.Close() in your graceful shutdown path so queued events flush', + ], + }, +}; diff --git a/src/lib/__tests__/wizard-can-use-tool.test.ts b/src/lib/__tests__/wizard-can-use-tool.test.ts index 67afbeccc..c15c25fdd 100644 --- a/src/lib/__tests__/wizard-can-use-tool.test.ts +++ b/src/lib/__tests__/wizard-can-use-tool.test.ts @@ -139,6 +139,24 @@ describe('bash fence — allows real toolchain commands (from skills + field log expect(allow('carthage bootstrap')).toBe('allow'); }); + it('go ecosystem', () => { + expect(allow('go get github.com/posthog/posthog-go')).toBe('allow'); + expect(allow('go mod tidy')).toBe('allow'); + expect(allow('go mod download')).toBe('allow'); + expect(allow('go build ./...')).toBe('allow'); + expect(allow('go vet ./...')).toBe('allow'); + expect(allow('go fmt ./...')).toBe('allow'); + expect(allow('go list -m all')).toBe('allow'); + // run/test/generate execute project code; mod edit rewrites requirements. + expect(allow('go run main.go')).toBe('deny'); + expect(allow('go test ./...')).toBe('deny'); + expect(allow('go generate ./...')).toBe('deny'); + expect(allow('go mod edit -replace example.com/x=evil.example/x')).toBe( + 'deny', + ); + expect(allow('go tool pprof')).toBe('deny'); + }); + it('android/jvm ecosystem', () => { expect(allow('./gradlew assembleDebug')).toBe('allow'); expect(allow('./gradlew :app:assembleDebug')).toBe('allow'); @@ -194,7 +212,7 @@ describe('bash fence — attack corpus (one test per bypass vector)', () => { expect(allow('bundle exec rspec')).toBe('deny'); expect(allow('composer run-script evil')).toBe('deny'); expect(allow('cargo run')).toBe('deny'); // no rust framework -> whole binary denied - expect(allow('go get github.com/x/y')).toBe('deny'); // no go framework + expect(allow('go run main.go')).toBe('deny'); // arbitrary code execution }); it('shell injection: separators, subshells, chaining', () => { diff --git a/src/lib/agent/bash-fence.ts b/src/lib/agent/bash-fence.ts index 9fe251eea..b50350132 100644 --- a/src/lib/agent/bash-fence.ts +++ b/src/lib/agent/bash-fence.ts @@ -86,6 +86,12 @@ const SIMPLE_MANAGERS: Record = { carthage: ['bootstrap', 'update'], }; +// go's verb list is closed: dependency + verify commands only. run/test/generate +// execute project-defined code; `go mod edit` can rewrite module requirements +// to arbitrary sources, so only the read/refresh mod subcommands are allowed. +const GO_SUBCOMMANDS = ['get', 'build', 'vet', 'fmt', 'version', 'list']; +const GO_MOD_SUBCOMMANDS = ['tidy', 'download', 'verify', 'graph', 'why']; + // Gradle tasks are verb-anchored camelCase: assembleDebug yes, publishToMavenCentral no. const GRADLE_EXACT_TASKS = new Set(['build', 'clean', 'dependencies']); const GRADLE_TASK_VERBS = ['assemble', 'compile', 'bundle', 'lint']; @@ -102,7 +108,8 @@ const ALLOWED_TOOLS_SUMMARY = 'composer (install|require|update|remove|show), bundle (install|add|remove|update|show|exec ), ' + 'gem (install|uninstall|list|search), swift (package|build), pod (install|update|search), carthage (bootstrap|update), ' + 'xcodebuild (build/clean/archive actions), gradle/gradlew (build|clean|dependencies|assemble*/compile*/bundle*/lint* tasks), ' + - 'mvn (install|compile|package|verify|dependency:tree).'; + 'mvn (install|compile|package|verify|dependency:tree), ' + + 'go (get|build|vet|fmt|version|list, mod tidy/download/verify/graph/why).'; function deny(analyticsReason: string, message: string): BashFenceDecision { return { allowed: false, message, analyticsReason }; @@ -273,6 +280,25 @@ function commandDecision(command: string): BashFenceDecision { ) { return { allowed: true }; } + if (bin === 'go') { + if (parts[1] === 'mod') { + if (parts[2] && GO_MOD_SUBCOMMANDS.includes(parts[2])) + return { allowed: true }; + return denyCommand( + command, + `Allowed go mod subcommands: ${GO_MOD_SUBCOMMANDS.join(', ')}.`, + ); + } + if (parts[1] && GO_SUBCOMMANDS.includes(parts[1])) return { allowed: true }; + return denyCommand( + command, + `Allowed go subcommands: ${GO_SUBCOMMANDS.join( + ', ', + )}, mod <${GO_MOD_SUBCOMMANDS.join( + '|', + )}>. go run/test/generate execute project code and are not allowed.`, + ); + } if (bin === 'uv' && parts[1] === 'pip') { if (parts[2] && PIP_SUBCOMMANDS.includes(parts[2])) return { allowed: true }; diff --git a/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts b/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts index f878a468a..47bb1e850 100644 --- a/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts +++ b/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts @@ -49,7 +49,7 @@ const INTEGRATION_ENTRIES = [ { id: 'integration-javascript_web', framework: 'javascript_web' }, { id: 'integration-ruby', framework: 'ruby' }, { id: 'integration-elixir' }, - { id: 'integration-go' }, + { id: 'integration-go', framework: 'go' }, { id: 'integration-swift', framework: 'swift' }, { id: 'integration-kmp', framework: 'kmp' }, { id: 'integration-flutter' }, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 78353cb61..364491665 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -100,6 +100,7 @@ export enum Integration { swift = 'swift', android = 'android', rails = 'rails', + go = 'go', // Language fallbacks. Keep javascriptNode last: it matches any package.json. python = 'python', diff --git a/src/lib/detection/__tests__/framework.test.ts b/src/lib/detection/__tests__/framework.test.ts index 075c46a29..21303338b 100644 --- a/src/lib/detection/__tests__/framework.test.ts +++ b/src/lib/detection/__tests__/framework.test.ts @@ -5,6 +5,7 @@ import { detectFramework } from '@lib/detection/framework'; import { Integration } from '@lib/constants'; import { ANDROID_AGENT_CONFIG } from '../../../frameworks/android/android-wizard-agent'; import { KMP_AGENT_CONFIG } from '../../../frameworks/kmp/kmp-wizard-agent'; +import { GO_AGENT_CONFIG } from '../../../frameworks/go/go-wizard-agent'; /** A throwaway project dir seeded with the given files. */ function makeProject(files: Record): string { @@ -137,6 +138,27 @@ describe('detectFramework (end-to-end over real project dirs)', () => { ); }); + test('a Go module project resolves to go', async () => { + const opts = project({ + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + 'main.go': 'package main', + }); + await expect(detectFramework(opts.installDir)).resolves.toBe( + Integration.go, + ); + }); + + test('a Go project with an embedded frontend package.json still resolves to go', async () => { + const opts = project({ + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + 'package.json': JSON.stringify({ devDependencies: { esbuild: '^0.20' } }), + 'package-lock.json': '{}', + }); + await expect(detectFramework(opts.installDir)).resolves.toBe( + Integration.go, + ); + }); + test('a Flutter project is not claimed by anything', async () => { const opts = project({ 'pubspec.yaml': 'name: my_flutter_app\nenvironment:\n sdk: ^3.0.0\n', @@ -169,6 +191,25 @@ describe('android detect', () => { }); }); +describe('go detect', () => { + const detect = GO_AGENT_CONFIG.detection.detect; + + test('claims a Go module project', async () => { + const opts = project({ 'go.mod': 'module example.com/app\n\ngo 1.22\n' }); + await expect(detect(opts)).resolves.toBe(true); + }); + + test('does not claim a go.mod without a module directive', async () => { + const opts = project({ 'go.mod': '// not a real module file\n' }); + await expect(detect(opts)).resolves.toBe(false); + }); + + test('does not claim a project without a go.mod', async () => { + const opts = project({ 'main.go': 'package main' }); + await expect(detect(opts)).resolves.toBe(false); + }); +}); + describe('kmp detect', () => { const detect = KMP_AGENT_CONFIG.detection.detect; diff --git a/src/lib/detection/__tests__/package-manager.test.ts b/src/lib/detection/__tests__/package-manager.test.ts index 27b468ad2..b79e0bfe1 100644 --- a/src/lib/detection/__tests__/package-manager.test.ts +++ b/src/lib/detection/__tests__/package-manager.test.ts @@ -7,6 +7,7 @@ import { composerPackageManager, swiftPackageManager, gradlePackageManager, + goModulesPackageManager, } from '@lib/detection/package-manager'; vi.mock('../../../utils/debug'); @@ -188,6 +189,7 @@ describe('static package manager helpers', () => { { fn: composerPackageManager, name: 'composer' }, { fn: swiftPackageManager, name: 'spm' }, { fn: gradlePackageManager, name: 'gradle' }, + { fn: goModulesPackageManager, name: 'go' }, ])('$name returns valid PackageManagerInfo', async ({ fn }) => { const result = await fn(); expect(result.detected).toHaveLength(1); diff --git a/src/lib/detection/agentic.ts b/src/lib/detection/agentic.ts index c808bf092..acd5427e3 100644 --- a/src/lib/detection/agentic.ts +++ b/src/lib/detection/agentic.ts @@ -74,10 +74,11 @@ export const PROJECT_MANIFESTS: readonly string[] = [ // Ruby / PHP 'Gemfile', 'composer.json', - // Rust / Go / Elixir / JVM / .NET: no framework targets yet, but found so + // Go + 'go.mod', + // Rust / Elixir / JVM / .NET: no framework targets yet, but found so // an existing PostHog SDK is reported (feeds self-driving's "continue" path). 'Cargo.toml', - 'go.mod', 'mix.exs', 'pom.xml', '*.csproj', diff --git a/src/lib/detection/package-manager.ts b/src/lib/detection/package-manager.ts index add3cc49d..320768daa 100644 --- a/src/lib/detection/package-manager.ts +++ b/src/lib/detection/package-manager.ts @@ -215,6 +215,25 @@ export function bundlerPackageManager(): Promise { }); } +// --------------------------------------------------------------------------- +// Go (modules) helper +// --------------------------------------------------------------------------- + +const GO_MODULES: DetectedPackageManager = { + name: 'go', + label: 'Go modules', + installCommand: 'go get', +}; + +export function goModulesPackageManager(): Promise { + return Promise.resolve({ + detected: [GO_MODULES], + primary: GO_MODULES, + recommendation: + 'Use Go modules (go get). Run go mod tidy after imports change.', + }); +} + // --------------------------------------------------------------------------- // Android (Gradle) helper // --------------------------------------------------------------------------- diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 2dfba061c..237b630ed 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -18,6 +18,7 @@ import { SWIFT_AGENT_CONFIG } from '@frameworks/swift/swift-wizard-agent'; import { KMP_AGENT_CONFIG } from '@frameworks/kmp/kmp-wizard-agent'; import { ANDROID_AGENT_CONFIG } from '@frameworks/android/android-wizard-agent'; import { RAILS_AGENT_CONFIG } from '@frameworks/rails/rails-wizard-agent'; +import { GO_AGENT_CONFIG } from '@frameworks/go/go-wizard-agent'; import { PYTHON_AGENT_CONFIG } from '@frameworks/python/python-wizard-agent'; import { RUBY_AGENT_CONFIG } from '@frameworks/ruby/ruby-wizard-agent'; import { JAVASCRIPT_NODE_AGENT_CONFIG } from '@frameworks/javascript-node/javascript-node-wizard-agent'; @@ -42,6 +43,7 @@ export const FRAMEWORK_REGISTRY: Record = { [Integration.swift]: SWIFT_AGENT_CONFIG, [Integration.android]: ANDROID_AGENT_CONFIG, [Integration.rails]: RAILS_AGENT_CONFIG, + [Integration.go]: GO_AGENT_CONFIG, [Integration.python]: PYTHON_AGENT_CONFIG, [Integration.ruby]: RUBY_AGENT_CONFIG, [Integration.javascriptNode]: JAVASCRIPT_NODE_AGENT_CONFIG,