-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: Fly Code — Jira, SECDESK, standup, command tray & security hardening #1464
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { Data } from "effect"; | ||
|
|
||
| export class CalendarError extends Data.TaggedError("CalendarError")<{ | ||
| readonly message: string; | ||
| }> {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import { Effect, Layer } from "effect"; | ||
| import { execSync } from "node:child_process"; | ||
| import { CalendarService } from "../Services/CalendarService.ts"; | ||
| import { CalendarError } from "../Errors.ts"; | ||
| import type { CalendarEvent } from "@t3tools/contracts"; | ||
|
|
||
| function runGcalcli(args: string): Effect.Effect<string, CalendarError> { | ||
| return Effect.try({ | ||
| try: () => execSync(`gcalcli ${args}`, { encoding: "utf-8", timeout: 30000 }), | ||
| catch: (e) => new CalendarError({ message: `gcalcli failed: ${e}` }), | ||
| }); | ||
| } | ||
|
|
||
| function parseTsvAgenda(tsv: string): CalendarEvent[] { | ||
| const lines = tsv.trim().split("\n").filter(Boolean); | ||
| const events: CalendarEvent[] = []; | ||
|
|
||
| for (const line of lines) { | ||
| const parts = line.split("\t"); | ||
| if (parts.length < 4) continue; | ||
|
|
||
| const [startDate, startTime, endDate, endTime, ...titleParts] = parts; | ||
| const title = titleParts.join("\t").trim(); | ||
| if (!title) continue; | ||
|
|
||
| const isAllDay = !startTime || startTime.trim() === ""; | ||
| const start = isAllDay ? (startDate ?? "").trim() : `${(startDate ?? "").trim()}T${(startTime ?? "").trim()}`; | ||
| const end = isAllDay ? (endDate ?? "").trim() : `${(endDate ?? "").trim()}T${(endTime ?? "").trim()}`; | ||
|
|
||
| events.push({ | ||
| title: title as CalendarEvent["title"], | ||
| start, | ||
| end, | ||
| isAllDay, | ||
| } as CalendarEvent); | ||
| } | ||
|
|
||
| return events; | ||
| } | ||
|
|
||
| export const CalendarServiceLive = Layer.succeed( | ||
| CalendarService, | ||
| CalendarService.of({ | ||
| agenda: ({ date }) => | ||
| Effect.gen(function* () { | ||
| const dateArg = date ?? "today"; | ||
| const output = yield* runGcalcli(`agenda ${dateArg} --tsv`); | ||
| return parseTsvAgenda(output); | ||
| }), | ||
|
|
||
| meetingPrep: ({ title, start }) => | ||
| Effect.gen(function* () { | ||
| // Generate meeting prep notes based on title and time | ||
| const notes = `## Meeting Prep: ${title}\n**Time:** ${start}\n\n### Talking Points\n- \n\n### Questions\n- \n\n### Action Items\n- `; | ||
| return { notes }; | ||
| }), | ||
| }), | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { ServiceMap, Effect } from "effect"; | ||
| import type { CalendarEvent } from "@t3tools/contracts"; | ||
| import type { CalendarError } from "../Errors.ts"; | ||
|
|
||
| export interface CalendarServiceShape { | ||
| readonly agenda: (input: { date?: string }) => Effect.Effect<ReadonlyArray<CalendarEvent>, CalendarError>; | ||
| readonly meetingPrep: (input: { title: string; start: string }) => Effect.Effect<{ notes: string }, CalendarError>; | ||
| } | ||
|
|
||
| export class CalendarService extends ServiceMap.Service<CalendarService, CalendarServiceShape>()( | ||
| "t3/calendar/Services/CalendarService", | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,278 @@ | ||
| import { readFileSync } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import { join } from "node:path"; | ||
|
|
||
| import { Effect, Layer } from "effect"; | ||
| import { BitbucketApiError } from "../Errors.ts"; | ||
| import { | ||
| BitbucketApi, | ||
| type BitbucketApiShape, | ||
| type BitbucketPullRequestSummary, | ||
| } from "../Services/BitbucketApi.ts"; | ||
|
|
||
| const DEFAULT_TIMEOUT_MS = 30_000; | ||
| const BITBUCKET_API_BASE = "https://api.bitbucket.org/2.0"; | ||
|
|
||
| interface NetrcCredentials { | ||
| login: string; | ||
| password: string; | ||
| } | ||
|
|
||
| function readNetrcCredentials(): NetrcCredentials | null { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low
🤖 Copy this AI Prompt to have your agent fix this: |
||
| try { | ||
| const netrcPath = join(homedir(), ".netrc"); | ||
| const content = readFileSync(netrcPath, "utf-8"); | ||
| const lines = content.split("\n").map((l) => l.trim()); | ||
| let inBitbucket = false; | ||
| let login: string | null = null; | ||
| let password: string | null = null; | ||
|
|
||
| for (const line of lines) { | ||
| if (line.startsWith("machine") && line.includes("bitbucket.org")) { | ||
| inBitbucket = true; | ||
| continue; | ||
| } | ||
| if (inBitbucket && line.startsWith("machine")) { | ||
| break; | ||
| } | ||
| if (inBitbucket) { | ||
| if (line.startsWith("login")) { | ||
| login = line.replace(/^login\s+/, "").trim(); | ||
| } | ||
| if (line.startsWith("password")) { | ||
| password = line.replace(/^password\s+/, "").trim(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (login && password) { | ||
| return { login, password }; | ||
| } | ||
| return null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function normalizeBitbucketPrState( | ||
| state: string, | ||
| ): "open" | "closed" | "merged" { | ||
| switch (state.toUpperCase()) { | ||
| case "OPEN": | ||
| return "open"; | ||
| case "MERGED": | ||
| return "merged"; | ||
| case "DECLINED": | ||
| case "SUPERSEDED": | ||
| return "closed"; | ||
| default: | ||
| return "closed"; | ||
| } | ||
| } | ||
|
|
||
| function parsePrSummary(raw: Record<string, unknown>): BitbucketPullRequestSummary | null { | ||
| const id = raw.id; | ||
| const title = raw.title; | ||
| const state = raw.state; | ||
| const links = raw.links as Record<string, unknown> | undefined; | ||
| const source = raw.source as Record<string, unknown> | undefined; | ||
| const destination = raw.destination as Record<string, unknown> | undefined; | ||
|
|
||
| if (typeof id !== "number" || typeof title !== "string" || typeof state !== "string") { | ||
| return null; | ||
| } | ||
|
|
||
| const htmlLink = links?.html as Record<string, unknown> | undefined; | ||
| const url = typeof htmlLink?.href === "string" ? htmlLink.href : ""; | ||
|
|
||
| const sourceBranch = source?.branch as Record<string, unknown> | undefined; | ||
| const sourceRefName = typeof sourceBranch?.name === "string" ? sourceBranch.name : ""; | ||
|
|
||
| const destBranch = destination?.branch as Record<string, unknown> | undefined; | ||
| const destRefName = typeof destBranch?.name === "string" ? destBranch.name : ""; | ||
|
|
||
| return { | ||
| id, | ||
| title, | ||
| url, | ||
| sourceRefName, | ||
| destinationRefName: destRefName, | ||
| state: normalizeBitbucketPrState(state), | ||
| }; | ||
| } | ||
|
|
||
| async function bitbucketFetch( | ||
| path: string, | ||
| options: { method?: string; body?: string; timeoutMs?: number } = {}, | ||
| ): Promise<unknown> { | ||
| const credentials = readNetrcCredentials(); | ||
| if (!credentials) { | ||
| throw new Error( | ||
| "Bitbucket credentials not found in ~/.netrc. Add an entry for machine bitbucket.org or api.bitbucket.org with your app password.", | ||
| ); | ||
| } | ||
|
|
||
| const url = `${BITBUCKET_API_BASE}${path}`; | ||
| const auth = Buffer.from(`${credentials.login}:${credentials.password}`).toString("base64"); | ||
|
|
||
| const controller = new AbortController(); | ||
| const timeout = setTimeout( | ||
| () => controller.abort(), | ||
| options.timeoutMs ?? DEFAULT_TIMEOUT_MS, | ||
| ); | ||
|
|
||
| try { | ||
| const response = await fetch(url, { | ||
| method: options.method ?? "GET", | ||
| headers: { | ||
| Authorization: `Basic ${auth}`, | ||
| "Content-Type": "application/json", | ||
| Accept: "application/json", | ||
| }, | ||
| body: options.body, | ||
| signal: controller.signal, | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const text = await response.text().catch(() => ""); | ||
| throw new Error(`Bitbucket API ${response.status}: ${text}`); | ||
| } | ||
|
|
||
| return await response.json(); | ||
| } finally { | ||
| clearTimeout(timeout); | ||
| } | ||
| } | ||
|
|
||
| function normalizeBitbucketApiError( | ||
| operation: string, | ||
| error: unknown, | ||
| ): BitbucketApiError { | ||
| if (error instanceof Error) { | ||
| if (error.message.includes("credentials not found")) { | ||
| return new BitbucketApiError({ | ||
| operation, | ||
| detail: | ||
| "Bitbucket credentials not configured. Add to ~/.netrc:\n machine bitbucket.org\n login your@email.com\n password YOUR_APP_PASSWORD", | ||
| cause: error, | ||
| }); | ||
| } | ||
| if (error.message.includes("401") || error.message.includes("403")) { | ||
| return new BitbucketApiError({ | ||
| operation, | ||
| detail: "Bitbucket authentication failed. Check your app password in ~/.netrc.", | ||
| cause: error, | ||
| }); | ||
| } | ||
| if (error.message.includes("404")) { | ||
| return new BitbucketApiError({ | ||
| operation, | ||
| detail: "Bitbucket resource not found. Check workspace and repository names.", | ||
| cause: error, | ||
| }); | ||
| } | ||
| return new BitbucketApiError({ | ||
| operation, | ||
| detail: `Bitbucket API call failed: ${error.message}`, | ||
| cause: error, | ||
| }); | ||
| } | ||
| return new BitbucketApiError({ | ||
| operation, | ||
| detail: "Bitbucket API call failed.", | ||
| cause: error, | ||
| }); | ||
| } | ||
|
|
||
| const makeBitbucketApi = Effect.sync(() => { | ||
| const service: BitbucketApiShape = { | ||
| listOpenPullRequests: (input) => | ||
| Effect.tryPromise({ | ||
| try: async () => { | ||
| const q = encodeURIComponent( | ||
| `source.branch.name="${input.sourceBranch}" AND state="OPEN"`, | ||
| ); | ||
| const limit = input.limit ?? 10; | ||
| const data = (await bitbucketFetch( | ||
| `/repositories/${input.workspace}/${input.repoSlug}/pullrequests?q=${q}&pagelen=${limit}`, | ||
| )) as { values?: unknown[] }; | ||
| const values = Array.isArray(data.values) ? data.values : []; | ||
| return values | ||
| .map((v) => parsePrSummary(v as Record<string, unknown>)) | ||
| .filter((v): v is BitbucketPullRequestSummary => v !== null); | ||
| }, | ||
| catch: (error) => normalizeBitbucketApiError("listOpenPullRequests", error), | ||
| }), | ||
|
|
||
| listAllPullRequests: (input) => | ||
| Effect.tryPromise({ | ||
| try: async () => { | ||
| const q = encodeURIComponent( | ||
| `source.branch.name="${input.sourceBranch}"`, | ||
| ); | ||
| const limit = input.limit ?? 20; | ||
| const data = (await bitbucketFetch( | ||
| `/repositories/${input.workspace}/${input.repoSlug}/pullrequests?q=${q}&pagelen=${limit}&sort=-updated_on`, | ||
| )) as { values?: unknown[] }; | ||
| const values = Array.isArray(data.values) ? data.values : []; | ||
| return values | ||
| .map((v) => parsePrSummary(v as Record<string, unknown>)) | ||
| .filter((v): v is BitbucketPullRequestSummary => v !== null); | ||
| }, | ||
| catch: (error) => normalizeBitbucketApiError("listAllPullRequests", error), | ||
| }), | ||
|
|
||
| getPullRequest: (input) => | ||
| Effect.tryPromise({ | ||
| try: async () => { | ||
| const data = (await bitbucketFetch( | ||
| `/repositories/${input.workspace}/${input.repoSlug}/pullrequests/${input.prId}`, | ||
| )) as Record<string, unknown>; | ||
| const pr = parsePrSummary(data); | ||
| if (!pr) { | ||
| throw new Error(`Failed to parse PR #${input.prId}`); | ||
| } | ||
| return pr; | ||
| }, | ||
| catch: (error) => normalizeBitbucketApiError("getPullRequest", error), | ||
| }), | ||
|
|
||
| createPullRequest: (input) => | ||
| Effect.tryPromise({ | ||
| try: async () => { | ||
| const body = JSON.stringify({ | ||
| title: input.title, | ||
| description: input.description, | ||
| source: { branch: { name: input.sourceBranch } }, | ||
| destination: { branch: { name: input.destinationBranch } }, | ||
| close_source_branch: true, | ||
| }); | ||
| const data = (await bitbucketFetch( | ||
| `/repositories/${input.workspace}/${input.repoSlug}/pullrequests`, | ||
| { method: "POST", body }, | ||
| )) as Record<string, unknown>; | ||
| const pr = parsePrSummary(data); | ||
| if (!pr) { | ||
| throw new Error("Failed to parse created PR response"); | ||
| } | ||
| return pr; | ||
| }, | ||
| catch: (error) => normalizeBitbucketApiError("createPullRequest", error), | ||
| }), | ||
|
|
||
| getDefaultBranch: (input) => | ||
| Effect.tryPromise({ | ||
| try: async () => { | ||
| const data = (await bitbucketFetch( | ||
| `/repositories/${input.workspace}/${input.repoSlug}`, | ||
| )) as { mainbranch?: { name?: string } }; | ||
| return data.mainbranch?.name ?? null; | ||
| }, | ||
| catch: (error) => normalizeBitbucketApiError("getDefaultBranch", error), | ||
| }), | ||
| }; | ||
|
|
||
| return service; | ||
| }); | ||
|
|
||
| export const BitbucketApiLive = Layer.effect(BitbucketApi, makeBitbucketApi); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
Layers/CalendarService.ts:7runGcalcliinterpolatesargsdirectly into a shell command string passed toexecSync, so malicious input like"; rm -rf /"executes arbitrary shell commands. Passargsas an array to avoid shell interpretation.🤖 Copy this AI Prompt to have your agent fix this: