Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/frameworks/go/go-wizard-agent.ts
Original file line number Diff line number Diff line change
@@ -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<GoContext> = {
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',
],
},
};
20 changes: 19 additions & 1 deletion src/lib/__tests__/wizard-can-use-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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', () => {
Expand Down
28 changes: 27 additions & 1 deletion src/lib/agent/bash-fence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ const SIMPLE_MANAGERS: Record<string, readonly string[]> = {
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'];
Expand All @@ -102,7 +108,8 @@ const ALLOWED_TOOLS_SUMMARY =
'composer (install|require|update|remove|show), bundle (install|add|remove|update|show|exec <lint tool>), ' +
'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 };
Expand Down Expand Up @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
41 changes: 41 additions & 0 deletions src/lib/detection/__tests__/framework.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string>): string {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions src/lib/detection/__tests__/package-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
composerPackageManager,
swiftPackageManager,
gradlePackageManager,
goModulesPackageManager,
} from '@lib/detection/package-manager';

vi.mock('../../../utils/debug');
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions src/lib/detection/agentic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
19 changes: 19 additions & 0 deletions src/lib/detection/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,25 @@ export function bundlerPackageManager(): Promise<PackageManagerInfo> {
});
}

// ---------------------------------------------------------------------------
// Go (modules) helper
// ---------------------------------------------------------------------------

const GO_MODULES: DetectedPackageManager = {
name: 'go',
label: 'Go modules',
installCommand: 'go get',
};

export function goModulesPackageManager(): Promise<PackageManagerInfo> {
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
// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -42,6 +43,7 @@ export const FRAMEWORK_REGISTRY: Record<Integration, FrameworkConfig> = {
[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,
Expand Down
Loading