diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 8a83fcd7df..a9b569dd86 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -884,6 +884,25 @@ describe("GitMasterConfigSchema", () => { //#then expect(result.success).toBe(false) }) + + test("accepts shell-safe git_env_prefix", () => { + const config = { git_env_prefix: "MY_HOOK=active" } + + const result = GitMasterConfigSchema.safeParse(config) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.git_env_prefix).toBe("MY_HOOK=active") + } + }) + + test("rejects git_env_prefix with shell metacharacters", () => { + const config = { git_env_prefix: "A=1; rm -rf /" } + + const result = GitMasterConfigSchema.safeParse(config) + + expect(result.success).toBe(false) + }) }) describe("skills schema", () => { diff --git a/src/config/schema.ts b/src/config/schema.ts index 0d2c590ba2..bcb36a1757 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -10,6 +10,7 @@ export * from "./schema/commands" export * from "./schema/dynamic-context-pruning" export * from "./schema/experimental" export * from "./schema/fallback-models" +export * from "./schema/git-env-prefix" export * from "./schema/git-master" export * from "./schema/hooks" export * from "./schema/notification" diff --git a/src/config/schema/git-env-prefix.ts b/src/config/schema/git-env-prefix.ts new file mode 100644 index 0000000000..65609c0b1c --- /dev/null +++ b/src/config/schema/git-env-prefix.ts @@ -0,0 +1,28 @@ +import { z } from "zod" + +const GIT_ENV_ASSIGNMENT_PATTERN = + /^(?:[A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_-]*)(?: [A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_-]*)*$/ + +export const GIT_ENV_PREFIX_VALIDATION_MESSAGE = + 'git_env_prefix must be empty or use shell-safe env assignments like "GIT_MASTER=1"' + +export function isValidGitEnvPrefix(value: string): boolean { + if (value === "") { + return true + } + + return GIT_ENV_ASSIGNMENT_PATTERN.test(value) +} + +export function assertValidGitEnvPrefix(value: string): string { + if (!isValidGitEnvPrefix(value)) { + throw new Error(GIT_ENV_PREFIX_VALIDATION_MESSAGE) + } + + return value +} + +export const GitEnvPrefixSchema = z + .string() + .refine(isValidGitEnvPrefix, { message: GIT_ENV_PREFIX_VALIDATION_MESSAGE }) + .default("GIT_MASTER=1") diff --git a/src/config/schema/git-master.ts b/src/config/schema/git-master.ts index 0574de860c..4c6f4bf656 100644 --- a/src/config/schema/git-master.ts +++ b/src/config/schema/git-master.ts @@ -1,10 +1,14 @@ import { z } from "zod" +import { GitEnvPrefixSchema } from "./git-env-prefix" + export const GitMasterConfigSchema = z.object({ /** Add "Ultraworked with Sisyphus" footer to commit messages (default: true). Can be boolean or custom string. */ commit_footer: z.union([z.boolean(), z.string()]).default(true), /** Add "Co-authored-by: Sisyphus" trailer to commit messages (default: true) */ include_co_authored_by: z.boolean().default(true), + /** Environment variable prefix for all git commands (default: "GIT_MASTER=1"). Set to "" to disable. Allows custom git hooks to detect git-master skill usage. */ + git_env_prefix: GitEnvPrefixSchema, }) export type GitMasterConfig = z.infer diff --git a/src/features/opencode-skill-loader/git-master-template-injection.test.ts b/src/features/opencode-skill-loader/git-master-template-injection.test.ts new file mode 100644 index 0000000000..60ea0f0b35 --- /dev/null +++ b/src/features/opencode-skill-loader/git-master-template-injection.test.ts @@ -0,0 +1,155 @@ +/// + +import { describe, it, expect } from "bun:test" +import { injectGitMasterConfig } from "./git-master-template-injection" + +const SAMPLE_TEMPLATE = [ + "# Git Master Agent", + "", + "## MODE DETECTION (FIRST STEP)", + "", + "Analyze the request.", + "", + "```bash", + "git status", + "git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null", + "MERGE_BASE=$(git merge-base HEAD main)", + "GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash $MERGE_BASE", + "```", + "", + "```", + "", +].join("\n") + +describe("#given git_env_prefix config", () => { + describe("#when default config (GIT_MASTER=1)", () => { + it("#then injects env prefix section before MODE DETECTION", () => { + const result = injectGitMasterConfig(SAMPLE_TEMPLATE, { + commit_footer: false, + include_co_authored_by: false, + git_env_prefix: "GIT_MASTER=1", + }) + + expect(result).toContain("## GIT COMMAND PREFIX (MANDATORY)") + expect(result).toContain("GIT_MASTER=1 git status") + expect(result).toContain("GIT_MASTER=1 git commit") + expect(result).toContain("GIT_MASTER=1 git push") + expect(result).toContain("EVERY git command MUST be prefixed with `GIT_MASTER=1`") + + const prefixIndex = result.indexOf("## GIT COMMAND PREFIX") + const modeIndex = result.indexOf("## MODE DETECTION") + expect(prefixIndex).toBeLessThan(modeIndex) + }) + }) + + describe("#when git_env_prefix is empty string", () => { + it("#then does NOT inject env prefix section", () => { + const result = injectGitMasterConfig(SAMPLE_TEMPLATE, { + commit_footer: false, + include_co_authored_by: false, + git_env_prefix: "", + }) + + expect(result).not.toContain("## GIT COMMAND PREFIX") + expect(result).not.toContain("GIT_MASTER=1") + expect(result).not.toContain("git_env_prefix") + }) + }) + + describe("#when git_env_prefix is custom value", () => { + it("#then injects custom prefix in section", () => { + const result = injectGitMasterConfig(SAMPLE_TEMPLATE, { + commit_footer: false, + include_co_authored_by: false, + git_env_prefix: "MY_HOOK=active", + }) + + expect(result).toContain("MY_HOOK=active git status") + expect(result).toContain("MY_HOOK=active git commit") + expect(result).not.toContain("GIT_MASTER=1") + }) + }) + + describe("#when git_env_prefix contains shell metacharacters", () => { + it("#then rejects the malicious value", () => { + expect(() => + injectGitMasterConfig(SAMPLE_TEMPLATE, { + commit_footer: false, + include_co_authored_by: false, + git_env_prefix: "A=1; rm -rf /", + }) + ).toThrow('git_env_prefix must be empty or use shell-safe env assignments like "GIT_MASTER=1"') + }) + }) + + describe("#when no config provided", () => { + it("#then uses default GIT_MASTER=1 prefix", () => { + const result = injectGitMasterConfig(SAMPLE_TEMPLATE) + + expect(result).toContain("GIT_MASTER=1 git status") + expect(result).toContain("## GIT COMMAND PREFIX (MANDATORY)") + }) + }) +}) + +describe("#given git_env_prefix with commit footer", () => { + describe("#when both env prefix and footer are enabled", () => { + it("#then commit examples include the env prefix", () => { + const result = injectGitMasterConfig(SAMPLE_TEMPLATE, { + commit_footer: true, + include_co_authored_by: false, + git_env_prefix: "GIT_MASTER=1", + }) + + expect(result).toContain("GIT_MASTER=1 git commit") + expect(result).toContain("Ultraworked with [Sisyphus]") + }) + }) + + describe("#when the template already contains bare git commands in bash blocks", () => { + it("#then prefixes every git invocation in the final output", () => { + const result = injectGitMasterConfig(SAMPLE_TEMPLATE, { + commit_footer: false, + include_co_authored_by: false, + git_env_prefix: "GIT_MASTER=1", + }) + + expect(result).toContain("GIT_MASTER=1 git status") + expect(result).toContain( + "GIT_MASTER=1 git merge-base HEAD main 2>/dev/null || GIT_MASTER=1 git merge-base HEAD master 2>/dev/null" + ) + expect(result).toContain("MERGE_BASE=$(GIT_MASTER=1 git merge-base HEAD main)") + expect(result).toContain( + "GIT_SEQUENCE_EDITOR=: GIT_MASTER=1 git rebase -i --autosquash $MERGE_BASE" + ) + }) + }) + + describe("#when env prefix disabled but footer enabled", () => { + it("#then commit examples have no env prefix", () => { + const result = injectGitMasterConfig(SAMPLE_TEMPLATE, { + commit_footer: true, + include_co_authored_by: false, + git_env_prefix: "", + }) + + expect(result).not.toContain("GIT_MASTER=1 git commit") + expect(result).toContain("git commit -m") + expect(result).toContain("Ultraworked with [Sisyphus]") + }) + }) + + describe("#when both env prefix and co-author are enabled", () => { + it("#then commit example includes prefix, footer, and co-author", () => { + const result = injectGitMasterConfig(SAMPLE_TEMPLATE, { + commit_footer: true, + include_co_authored_by: true, + git_env_prefix: "GIT_MASTER=1", + }) + + expect(result).toContain("GIT_MASTER=1 git commit") + expect(result).toContain("Ultraworked with [Sisyphus]") + expect(result).toContain("Co-authored-by: Sisyphus") + }) + }) +}) diff --git a/src/features/opencode-skill-loader/git-master-template-injection.ts b/src/features/opencode-skill-loader/git-master-template-injection.ts index f6815798c7..812fa228fe 100644 --- a/src/features/opencode-skill-loader/git-master-template-injection.ts +++ b/src/features/opencode-skill-loader/git-master-template-injection.ts @@ -1,14 +1,88 @@ -import type { GitMasterConfig } from "../../config/schema" +import { assertValidGitEnvPrefix, type GitMasterConfig } from "../../config/schema" + +const BASH_CODE_BLOCK_PATTERN = /```bash\r?\n([\s\S]*?)```/g +const LEADING_GIT_COMMAND_PATTERN = /^([ \t]*(?:[A-Za-z_][A-Za-z0-9_]*=[^ \t]+\s+)*)git(?=[ \t]|$)/gm +const INLINE_GIT_COMMAND_PATTERN = /([;&|()][ \t]*)git(?=[ \t]|$)/g export function injectGitMasterConfig(template: string, config?: GitMasterConfig): string { const commitFooter = config?.commit_footer ?? true const includeCoAuthoredBy = config?.include_co_authored_by ?? true + const gitEnvPrefix = assertValidGitEnvPrefix(config?.git_env_prefix ?? "GIT_MASTER=1") + + let result = gitEnvPrefix ? injectGitEnvPrefix(template, gitEnvPrefix) : template + + if (commitFooter || includeCoAuthoredBy) { + const injection = buildCommitFooterInjection(commitFooter, includeCoAuthoredBy, gitEnvPrefix) + const insertionPoint = result.indexOf("```\n") - if (!commitFooter && !includeCoAuthoredBy) { - return template + result = + insertionPoint !== -1 + ? result.slice(0, insertionPoint) + + "```\n\n" + + injection + + "\n" + + result.slice(insertionPoint + "```\n".length) + : result + "\n\n" + injection } + return gitEnvPrefix ? prefixGitCommandsInBashCodeBlocks(result, gitEnvPrefix) : result +} + +function injectGitEnvPrefix(template: string, prefix: string): string { + const envPrefixSection = [ + "## GIT COMMAND PREFIX (MANDATORY)", + "", + ``, + `**EVERY git command MUST be prefixed with \`${prefix}\`.**`, + "", + "This allows custom git hooks to detect when git-master skill is active.", + "", + "```bash", + `${prefix} git status`, + `${prefix} git add `, + `${prefix} git commit -m "message"`, + `${prefix} git push`, + `${prefix} git rebase ...`, + `${prefix} git log ...`, + "```", + "", + "**NO EXCEPTIONS. Every `git` invocation must include this prefix.**", + ``, + ].join("\n") + + const modeDetectionMarker = "## MODE DETECTION (FIRST STEP)" + const markerIndex = template.indexOf(modeDetectionMarker) + if (markerIndex !== -1) { + return ( + template.slice(0, markerIndex) + + envPrefixSection + + "\n\n---\n\n" + + template.slice(markerIndex) + ) + } + + return envPrefixSection + "\n\n---\n\n" + template +} + +function prefixGitCommandsInBashCodeBlocks(template: string, prefix: string): string { + return template.replace(BASH_CODE_BLOCK_PATTERN, (block, codeBlock: string) => { + return block.replace(codeBlock, prefixGitCommandsInCodeBlock(codeBlock, prefix)) + }) +} + +function prefixGitCommandsInCodeBlock(codeBlock: string, prefix: string): string { + return codeBlock + .replace(LEADING_GIT_COMMAND_PATTERN, `$1${prefix} git`) + .replace(INLINE_GIT_COMMAND_PATTERN, `$1${prefix} git`) +} + +function buildCommitFooterInjection( + commitFooter: boolean | string, + includeCoAuthoredBy: boolean, + gitEnvPrefix: string, +): string { const sections: string[] = [] + const cmdPrefix = gitEnvPrefix ? `${gitEnvPrefix} ` : "" sections.push("### 5.5 Commit Footer & Co-Author") sections.push("") @@ -43,7 +117,7 @@ export function injectGitMasterConfig(template: string, config?: GitMasterConfig sections.push("**Example (both enabled):**") sections.push("```bash") sections.push( - `git commit -m "{Commit Message}" -m "${footerText}" -m "Co-authored-by: Sisyphus "` + `${cmdPrefix}git commit -m "{Commit Message}" -m "${footerText}" -m "Co-authored-by: Sisyphus "` ) sections.push("```") } else if (commitFooter) { @@ -53,29 +127,16 @@ export function injectGitMasterConfig(template: string, config?: GitMasterConfig : "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" sections.push("**Example:**") sections.push("```bash") - sections.push(`git commit -m "{Commit Message}" -m "${footerText}"`) + sections.push(`${cmdPrefix}git commit -m "{Commit Message}" -m "${footerText}"`) sections.push("```") } else if (includeCoAuthoredBy) { sections.push("**Example:**") sections.push("```bash") sections.push( - "git commit -m \"{Commit Message}\" -m \"Co-authored-by: Sisyphus \"" + `${cmdPrefix}git commit -m "{Commit Message}" -m "Co-authored-by: Sisyphus "` ) sections.push("```") } - const injection = sections.join("\n") - - const insertionPoint = template.indexOf("```\n") - if (insertionPoint !== -1) { - return ( - template.slice(0, insertionPoint) + - "```\n\n" + - injection + - "\n" + - template.slice(insertionPoint + "```\n".length) - ) - } - - return template + "\n\n" + injection + return sections.join("\n") } diff --git a/src/features/opencode-skill-loader/skill-content.test.ts b/src/features/opencode-skill-loader/skill-content.test.ts index 506de215b2..64d6d5bf4d 100644 --- a/src/features/opencode-skill-loader/skill-content.test.ts +++ b/src/features/opencode-skill-loader/skill-content.test.ts @@ -228,6 +228,7 @@ describe("resolveMultipleSkillsAsync", () => { gitMasterConfig: { commit_footer: false, include_co_authored_by: false, + git_env_prefix: "GIT_MASTER=1", }, } @@ -249,6 +250,7 @@ describe("resolveMultipleSkillsAsync", () => { gitMasterConfig: { commit_footer: true, include_co_authored_by: true, + git_env_prefix: "GIT_MASTER=1", }, } @@ -269,6 +271,7 @@ describe("resolveMultipleSkillsAsync", () => { gitMasterConfig: { commit_footer: true, include_co_authored_by: false, + git_env_prefix: "GIT_MASTER=1", }, } @@ -302,6 +305,7 @@ describe("resolveMultipleSkillsAsync", () => { gitMasterConfig: { commit_footer: false, include_co_authored_by: true, + git_env_prefix: "GIT_MASTER=1", }, } @@ -322,6 +326,7 @@ describe("resolveMultipleSkillsAsync", () => { gitMasterConfig: { commit_footer: customFooter, include_co_authored_by: false, + git_env_prefix: "GIT_MASTER=1", }, } @@ -341,6 +346,7 @@ describe("resolveMultipleSkillsAsync", () => { gitMasterConfig: { commit_footer: true, include_co_authored_by: false, + git_env_prefix: "GIT_MASTER=1", }, }