Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { TwitterApi, TwitterApiv2, TwitterApiv1 } from "twitter-api-v2";
import { TwitterActionProvider } from "./twitterActionProvider";
import { TweetUserMentionTimelineV2Paginator } from "twitter-api-v2";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";

const MOCK_CONFIG = {
apiKey: "test-api-key",
Expand All @@ -16,7 +19,6 @@ const MOCK_TWEET = "Hello, world!";
const MOCK_TWEET_ID = "0123456789012345678";
const MOCK_TWEET_REPLY = "Hello again!";
const MOCK_MEDIA_ID = "987654321";
const MOCK_FILE_PATH = "/path/to/image.jpg";

describe("TwitterActionProvider", () => {
let mockClient: jest.Mocked<TwitterApiv2>;
Expand Down Expand Up @@ -278,14 +280,27 @@ describe("TwitterActionProvider", () => {
});

describe("Upload Media Action", () => {
let tmpDir: string;
let prevCwd: string;
const fileName = "image.jpg";

beforeEach(() => {
mockUploadMedia.mockResolvedValue(MOCK_MEDIA_ID);
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "twitter-upload-"));
prevCwd = process.cwd();
process.chdir(tmpDir);
fs.writeFileSync(path.join(tmpDir, fileName), "x");
});

afterEach(() => {
process.chdir(prevCwd);
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it("should successfully upload media", async () => {
const response = await provider.uploadMedia({ filePath: MOCK_FILE_PATH });
const response = await provider.uploadMedia({ filePath: fileName });

expect(mockUploadMedia).toHaveBeenCalledWith(MOCK_FILE_PATH);
expect(mockUploadMedia).toHaveBeenCalledWith(fs.realpathSync(path.join(tmpDir, fileName)));
expect(response).toContain("Successfully uploaded media to Twitter");
expect(response).toContain(MOCK_MEDIA_ID);
});
Expand All @@ -294,12 +309,20 @@ describe("TwitterActionProvider", () => {
const error = new Error("Invalid file format");
mockUploadMedia.mockRejectedValue(error);

const response = await provider.uploadMedia({ filePath: MOCK_FILE_PATH });
const response = await provider.uploadMedia({ filePath: fileName });

expect(mockUploadMedia).toHaveBeenCalledWith(MOCK_FILE_PATH);
expect(mockUploadMedia).toHaveBeenCalledWith(fs.realpathSync(path.join(tmpDir, fileName)));
expect(response).toContain("Error uploading media to Twitter");
expect(response).toContain(error.message);
});

it("should reject paths outside the working directory", async () => {
const response = await provider.uploadMedia({ filePath: "/etc/passwd" });

expect(mockUploadMedia).not.toHaveBeenCalled();
expect(response).toContain("Error uploading media to Twitter");
expect(response).toMatch(/working directory/i);
});
});

describe("Network Support", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
TwitterPostTweetReplySchema,
TwitterUploadMediaSchema,
} from "./schemas";
import { resolveSafeLocalMediaPath } from "./utils";

/**
* Configuration options for the TwitterActionProvider.
Expand Down Expand Up @@ -234,7 +235,8 @@ A failure response will return a message with the Twitter API request error:
})
async uploadMedia(args: z.infer<typeof TwitterUploadMediaSchema>): Promise<string> {
try {
const mediaId = await this.getClient().v1.uploadMedia(args.filePath);
const safePath = resolveSafeLocalMediaPath(args.filePath);
const mediaId = await this.getClient().v1.uploadMedia(safePath);
return `Successfully uploaded media to Twitter: ${mediaId}`;
} catch (error) {
return `Error uploading media to Twitter: ${error}`;
Expand Down
23 changes: 23 additions & 0 deletions typescript/agentkit/src/action-providers/twitter/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";

import { resolveSafeLocalMediaPath } from "./utils";

describe("resolveSafeLocalMediaPath", () => {
it("allows files under cwd and rejects escapes", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "twitter-media-"));
const prev = process.cwd();
try {
process.chdir(tmp);
const inside = path.join(tmp, "image.jpg");
fs.writeFileSync(inside, "x");
expect(resolveSafeLocalMediaPath("image.jpg")).toBe(fs.realpathSync(inside));
expect(() => resolveSafeLocalMediaPath("../outside.jpg")).toThrow(/working directory/i);
expect(() => resolveSafeLocalMediaPath("/etc/passwd")).toThrow(/working directory/i);
} finally {
process.chdir(prev);
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
20 changes: 20 additions & 0 deletions typescript/agentkit/src/action-providers/twitter/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import fs from "fs";
import path from "path";

/**
* Resolve a local media path and require it to stay under process.cwd()
* (realpath), so agent-supplied paths cannot read arbitrary files for
* Twitter media upload. Twin of zora/flaunch resolveSafeLocalImagePath.
*/
export function resolveSafeLocalMediaPath(filePath: string): string {
const root = fs.realpathSync(process.cwd());
const resolved = path.resolve(root, filePath);
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
throw new Error("Local media path must be within the working directory");
}
const real = fs.realpathSync(resolved);
if (real !== root && !real.startsWith(root + path.sep)) {
throw new Error("Local media path escapes the working directory");
}
return real;
}
Loading