Skip to content
Merged
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
41 changes: 27 additions & 14 deletions scripts/tui-host.no-jest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,34 +253,47 @@ async function main() {
// env file, and the screens walked.
if (process.env.E2E_RESULT_JSON) {
const appDir = process.env.APP_DIR!;
let deps: string[] = [];
// One dependency-name pattern per ecosystem manifest. A run only needs
// the names, so a line-level scan beats per-format parsers.
const MANIFESTS: Array<[string, RegExp]> = [
['pubspec.yaml', /^ {2}([A-Za-z_][A-Za-z0-9_]*)\s*:/gm],
['go.mod', /^\s*([\w.\/-]+)\s+v[\w.-]+/gm],
['Cargo.toml', /^([A-Za-z0-9_-]+)\s*=/gm],
['pom.xml', /<artifactId>([^<]+)<\/artifactId>/g],
['build.gradle', /['"]([\w.-]+:[\w.-]+)[:'"]/g],
['mix.exs', /\{:([a-z_]+)\s*,/g],
];
const deps: string[] = [];
try {
// package.json needs a real parse: a line scan would also match script
// names, and only the dependency blocks carry dependencies.
const pkg = JSON.parse(
fs.readFileSync(`${appDir}/package.json`, 'utf8'),
);
deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
deps.push(
...Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }),
);
} catch {
/* some frameworks have no package.json */
/* not a JS project */
}
try {
// Dart/Flutter declares dependencies in pubspec.yaml, so a Flutter run
// reports no dependency at all when only package.json is read.
const pubspec = fs.readFileSync(`${appDir}/pubspec.yaml`, 'utf8');
for (const line of pubspec.split('\n')) {
const dep = /^\s{2}([A-Za-z_][A-Za-z0-9_]*)\s*:/.exec(line);
if (dep) deps.push(dep[1]);
for (const [file, pattern] of MANIFESTS) {
try {
const text = fs.readFileSync(`${appDir}/${file}`, 'utf8');
for (const match of text.matchAll(pattern)) deps.push(match[1]);
} catch {
/* app doesn't use this ecosystem */
}
} catch {
/* not a Dart project */
}
const posthogDeps = deps.filter((d) => d.includes('posthog'));
const posthogDeps = [
...new Set(deps.filter((d) => d.toLowerCase().includes('posthog'))),
];
let envFile: string | null = null;
try {
const hit = fs
.readdirSync(appDir)
.find(
(f) =>
f.startsWith('.env') &&
(f.startsWith('.env') || f.endsWith('.env')) &&
/posthog/i.test(fs.readFileSync(`${appDir}/${f}`, 'utf8')),
);
envFile = hit ? `${appDir}/${hit}` : null;
Expand Down
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',
],
},
};
23 changes: 22 additions & 1 deletion src/lib/__tests__/wizard-can-use-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,27 @@ describe('bash fence — allows real toolchain commands (from skills + field log
expect(allow('xcodegen dump')).toBe('deny');
});

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');
// -toolexec runs an arbitrary program during an otherwise-allowed build.
expect(allow('go build -toolexec=/tmp/x.sh ./...')).toBe('deny');
expect(allow('go vet -toolexec /tmp/x.sh ./...')).toBe('deny');
});

it('android/jvm ecosystem', () => {
expect(allow('./gradlew assembleDebug')).toBe('allow');
expect(allow('./gradlew :app:assembleDebug')).toBe('allow');
Expand Down Expand Up @@ -214,7 +235,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
39 changes: 38 additions & 1 deletion src/lib/agent/bash-fence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ const SIMPLE_MANAGERS: Record<string, readonly string[]> = {
xcodegen: ['generate'],
};

// 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'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Build output can install executable Git hooks

Allowing go build with unrestricted flags admits commands such as go build -o .git/hooks/pre-commit ./cmd/payload. An attacker-controlled repository can use this to install its payload as an executable hook that runs during a later commit; reject -o or validate that its destination cannot target .git or other executable control paths.

const GO_MOD_SUBCOMMANDS = ['tidy', 'download', 'verify', 'graph', 'why'];
// `pub run` executes arbitrary packages, so it is excluded like npm exec.
const PUB_SUBCOMMANDS = ['add', 'remove', 'get', 'upgrade', 'outdated', 'deps'];
// flutter/dart verbs beyond `pub`. build (native build scripts) and analyze
Expand Down Expand Up @@ -120,7 +125,8 @@ const ALLOWED_TOOLS_SUMMARY =
'gem (install|uninstall|list|search), swift (package|build), pod (install|update|search), carthage (bootstrap|update), ' +
'xcodegen (generate), xcodebuild (build/clean/archive actions), gradle/gradlew (build|clean|dependencies|assemble*/compile*/bundle*/lint* tasks), ' +
'mvn (install|compile|package|verify|dependency:tree), ' +
'flutter/dart (pub add/remove/get/upgrade/outdated/deps, analyze, build, clean, doctor).';
'flutter/dart (pub add/remove/get/upgrade/outdated/deps, analyze, build, clean, doctor), ' +
'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 @@ -344,6 +350,37 @@ function commandDecision(command: string): BashFenceDecision {
)}>. run/test execute arbitrary code and are not allowed.`,
);
}
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])) {
// -toolexec runs an arbitrary program on every build/vet action, so the
// allowed verbs above are not safe with it. Deny it explicitly.
const toolexec = parts
.slice(2)
.find((p) => p === '-toolexec' || p.startsWith('-toolexec='));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: Arbitrary program execution through Go tool flags

A malicious repository can include an executable payload and prompt the agent to run go vet -vettool=./payload ./...; this passes the fence and causes Go to execute the payload. The current check also misses --toolexec, so block both spellings of both execution flags.

Suggested change
.find((p) => p === '-toolexec' || p.startsWith('-toolexec='));
.find((p) => /^--?(?:toolexec|vettool)(?:=|$)/.test(p));

if (toolexec)
return denyCommand(
command,
'The -toolexec flag runs an arbitrary program during the build and is not allowed.',
);
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', framework: '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 @@ -97,6 +97,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';
import { FLUTTER_AGENT_CONFIG } from '../../../frameworks/flutter/flutter-wizard-agent';

/** A throwaway project dir seeded with the given files. */
Expand Down Expand Up @@ -149,6 +150,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 resolves to flutter, not its android/ subtree', async () => {
const opts = project({
'pubspec.yaml': FLUTTER_PUBSPEC,
Expand Down Expand Up @@ -228,6 +250,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,
pubPackageManager,
} from '@lib/detection/package-manager';

Expand Down Expand Up @@ -189,6 +190,7 @@ describe('static package manager helpers', () => {
{ fn: composerPackageManager, name: 'composer' },
{ fn: swiftPackageManager, name: 'spm' },
{ fn: gradlePackageManager, name: 'gradle' },
{ fn: goModulesPackageManager, name: 'go' },
{ fn: pubPackageManager, name: 'pub' },
])('$name returns valid PackageManagerInfo', async ({ fn }) => {
const result = await fn();
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 @@ -76,10 +76,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.',
});
}

// ---------------------------------------------------------------------------
// Flutter (pub) helper
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading