Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
110 changes: 109 additions & 1 deletion packages/agent/src/signed-commit-artefacts.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import {
reportCommitArtefacts,
reportTaskRunBranch,
resolveSandboxPosthogApi,
} from "./signed-commit-artefacts";

const ENV = {
Expand All @@ -12,6 +25,97 @@ const ENV = {

// Point the env-file read at a path that never exists so only `env` is used.
const NO_ENV_FILE = "/nonexistent/agent-env";
const TEST_OAUTH_ENV_FILE = path.join(
tmpdir(),
`posthog-agent-oauth-env-${process.pid}`,
);

beforeAll(async () => {
await writeFile(TEST_OAUTH_ENV_FILE, "POSTHOG_PERSONAL_API_KEY=pha_test\0");
});

afterAll(async () => {
await rm(TEST_OAUTH_ENV_FILE, { force: true });
});

describe("resolveSandboxPosthogApi", () => {
it("reads the rotating API key from the dedicated OAuth file", async () => {
const directory = await mkdtemp(
path.join(tmpdir(), "sandbox-posthog-api-"),
);
const envFilePath = path.join(directory, "agent-env");
const oauthEnvFilePath = path.join(directory, "agent-oauth-env");

try {
await writeFile(
envFilePath,
"POSTHOG_API_URL=https://us.posthog.com\0POSTHOG_PROJECT_ID=7\0",
);
await writeFile(
oauthEnvFilePath,
"POSTHOG_PERSONAL_API_KEY=pha_refreshed\0",
);

expect(
resolveSandboxPosthogApi({}, envFilePath, oauthEnvFilePath),
).toEqual({
apiUrl: "https://us.posthog.com",
apiKey: "pha_refreshed",
projectId: 7,
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});

it("fails closed without the dedicated OAuth file", () => {
expect(
resolveSandboxPosthogApi(ENV, NO_ENV_FILE, NO_ENV_FILE),
).toBeUndefined();
});

it("does not resurrect a stale token when the OAuth file is empty", async () => {
const directory = await mkdtemp(
path.join(tmpdir(), "sandbox-posthog-api-"),
);
const envFilePath = path.join(directory, "agent-env");
const oauthEnvFilePath = path.join(directory, "agent-oauth-env");

try {
await writeFile(
envFilePath,
"POSTHOG_API_URL=https://us.posthog.com\0POSTHOG_PERSONAL_API_KEY=pha_stale\0POSTHOG_PROJECT_ID=7\0",
);
await writeFile(oauthEnvFilePath, "");

expect(
resolveSandboxPosthogApi(ENV, envFilePath, oauthEnvFilePath),
).toBeUndefined();
} finally {
await rm(directory, { recursive: true, force: true });
}
});

it("fails closed when the managed OAuth file is unreadable", async () => {
const directory = await mkdtemp(
path.join(tmpdir(), "sandbox-posthog-api-"),
);
const envFilePath = path.join(directory, "agent-env");

try {
await writeFile(
envFilePath,
"POSTHOG_API_URL=https://us.posthog.com\0POSTHOG_PROJECT_ID=7\0",
);

expect(
resolveSandboxPosthogApi(ENV, envFilePath, directory),
).toBeUndefined();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});

const RESULT = {
branch: "posthog-code/fix-foo",
Expand Down Expand Up @@ -59,6 +163,7 @@ describe("reportCommitArtefacts", () => {
message: "fix: foo",
env: ENV,
envFilePath: NO_ENV_FILE,
oauthEnvFilePath: TEST_OAUTH_ENV_FILE,
});

const lookupCalls = fetchMock.mock.calls.filter(([url]) =>
Expand Down Expand Up @@ -117,6 +222,7 @@ describe("reportCommitArtefacts", () => {
message: "fix: foo",
env: ENV,
envFilePath: NO_ENV_FILE,
oauthEnvFilePath: TEST_OAUTH_ENV_FILE,
}),
).resolves.toBeUndefined();
});
Expand All @@ -140,6 +246,7 @@ describe("reportCommitArtefacts", () => {
message: "fix: foo",
env: ENV,
envFilePath: NO_ENV_FILE,
oauthEnvFilePath: TEST_OAUTH_ENV_FILE,
});

// Both commits attempted despite the first failing.
Expand Down Expand Up @@ -175,6 +282,7 @@ describe("reportTaskRunBranch", () => {
branch: "posthog-code/fix-foo",
env: ENV,
envFilePath: NO_ENV_FILE,
oauthEnvFilePath: TEST_OAUTH_ENV_FILE,
});

expect(fetchMock).toHaveBeenCalledOnce();
Expand Down
54 changes: 42 additions & 12 deletions packages/agent/src/signed-commit-artefacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { SignedCommitResult } from "@posthog/git/signed-commit";
import { PostHogAPIClient } from "./posthog-api";

const SANDBOX_ENV_FILE = "/tmp/agent-env";
const SANDBOX_OAUTH_ENV_FILE = "/tmp/agent-oauth-env";

/**
* Best-effort "commit hook": after a successful signed-commit push, record one `commit`
Expand All @@ -11,11 +12,10 @@ const SANDBOX_ENV_FILE = "/tmp/agent-env";
* endpoint reads the `X-PostHog-Task-Id` header, never the model.
*
* Credentials come from the sandbox environment (`POSTHOG_API_URL` /
* `POSTHOG_PERSONAL_API_KEY` / `POSTHOG_PROJECT_ID`), preferring the live agentsh env file
* for the key so a mid-session token refresh is picked up — the same pattern as
* `resolveGithubToken`. Works identically from the Claude in-process server and the Codex
* stdio child (both inherit the sandbox env). Never throws: a failed artefact post must not
* fail the commit that already landed.
* `POSTHOG_PERSONAL_API_KEY` / `POSTHOG_PROJECT_ID`), preferring the dedicated live agentsh
* OAuth file for the key so a mid-session token refresh is picked up — the same pattern as
* `resolveGithubToken`. Never throws: a failed artefact post must not fail the commit that
* already landed.
*/

interface SandboxPosthogApi {
Expand All @@ -41,28 +41,48 @@ function readSandboxEnvFile(envFilePath: string): Record<string, string> {
}
}

function readSandboxOauthToken(oauthEnvFilePath: string): string | undefined {
let raw: string;
try {
raw = readFileSync(oauthEnvFilePath, "utf8");
} catch {
// The dedicated credential channel is mandatory. Missing and unreadable
// files both fail closed.
return undefined;
}

// The backend revokes OAuth access by truncating this managed file. Its
// presence is authoritative even when empty.
if (raw.trim() === "") {
return "";
}
const oauthEnv = readSandboxEnvFile(oauthEnvFilePath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I believe here we are reading thefile a second time after validating the first snapshot, a refresh between those reads could validate one generation and parse another. Could we parse POSTHOG_PERSONAL_API_KEY directly from raw so each resolution uses one consistent snapshot?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 93fc7d0: the resolver now parses the token directly from the single readFileSync snapshot, so validation and parsing cannot observe different file generations.

return oauthEnv.POSTHOG_PERSONAL_API_KEY ?? "";
}

export function resolveSandboxPosthogApi(
env: Record<string, string | undefined> = process.env,
envFilePath: string = SANDBOX_ENV_FILE,
oauthEnvFilePath: string = SANDBOX_OAUTH_ENV_FILE,
): SandboxPosthogApi | undefined {
const fileEnv = readSandboxEnvFile(envFilePath);
const oauthToken = readSandboxOauthToken(oauthEnvFilePath);

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: Managed OAuth token can be redirected to an untrusted host

The managed token is paired with POSTHOG_API_URL from /tmp/agent-env. A malicious repository can induce the agent to overwrite that general environment file with an attacker-controlled URL and then invoke artifact upload or signed commit reporting, causing PostHogAPIClient to send Authorization: Bearer <token> to that host. Source the API origin from the protected credential configuration or validate it against an explicit HTTPS PostHog-origin allowlist before constructing the client.

const apiUrl = fileEnv.POSTHOG_API_URL ?? env.POSTHOG_API_URL;
const apiKey =
fileEnv.POSTHOG_PERSONAL_API_KEY ?? env.POSTHOG_PERSONAL_API_KEY;
const projectId = Number(
fileEnv.POSTHOG_PROJECT_ID ?? env.POSTHOG_PROJECT_ID,
);
if (!apiUrl || !apiKey || !Number.isFinite(projectId) || projectId <= 0) {
if (!apiUrl || !oauthToken || !Number.isFinite(projectId) || projectId <= 0) {
return undefined;
}
return { apiUrl, apiKey, projectId };
return { apiUrl, apiKey: oauthToken, projectId };
}

export function createSandboxPosthogClient(
env?: Record<string, string | undefined>,
envFilePath?: string,
oauthEnvFilePath?: string,
): PostHogAPIClient | undefined {
const api = resolveSandboxPosthogApi(env, envFilePath);
const api = resolveSandboxPosthogApi(env, envFilePath, oauthEnvFilePath);
if (!api) {
return undefined;
}
Expand All @@ -80,13 +100,18 @@ export async function reportCommitArtefacts(opts: {
message: string;
env?: Record<string, string | undefined>;
envFilePath?: string;
oauthEnvFilePath?: string;
}): Promise<void> {
const { taskId, result, message } = opts;
if (!taskId) {
return; // Local/desktop run — no task to attribute or associate through.
}
try {
const client = createSandboxPosthogClient(opts.env, opts.envFilePath);
const client = createSandboxPosthogClient(
opts.env,
opts.envFilePath,
opts.oauthEnvFilePath,
);
if (!client) {
return; // No sandbox PostHog credentials — nothing to report to.
}
Expand Down Expand Up @@ -121,12 +146,17 @@ export async function reportTaskRunBranch(opts: {
branch: string;
env?: Record<string, string | undefined>;
envFilePath?: string;
oauthEnvFilePath?: string;
}): Promise<void> {
if (!opts.taskId || !opts.taskRunId) {
return;
}
try {
const client = createSandboxPosthogClient(opts.env, opts.envFilePath);
const client = createSandboxPosthogClient(
opts.env,
opts.envFilePath,
opts.oauthEnvFilePath,
);
if (!client) {
return;
}
Expand Down
Loading