Summary
When a workflow runs in repo A but configures safe-outputs.create-pull-request.target-repo to repo B, the target-repo value is correctly compiled into GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG (used by the conclusion job) but is missing from the config.json file (used by the MCP server during the agent job). This causes the MCP server to default to GITHUB_REPOSITORY (the owning repo), making cross-repo PRs impossible — the agent cannot target the configured repo.
Reproduction
Workflow file (.github/workflows/proxy-frontend-refactor.md in caido/ai-ops):
---
checkout:
- repository: caido/proxy-frontend
path: ./proxy-frontend
ref: dev
current: true
safe-outputs:
create-pull-request:
target-repo: caido/proxy-frontend
title-prefix: "[refactor] "
base-branch: dev
reviewers: [corb3nik]
draft: true
---
# Proxy-frontend Refactor
Add a single dummy file to the root of proxy-frontend.
Expected behavior: The MCP server's config.json includes "target-repo":"caido/proxy-frontend". The agent can omit repo and the default resolves to caido/proxy-frontend. Passing repo: "caido/proxy-frontend" explicitly also works.
Actual behavior: The compiled config.json is:
{"create_pull_request":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}}
No target-repo, no base_branch, no draft, no reviewers, no title_prefix. The MCP server's getDefaultTargetRepo() falls back to GITHUB_REPOSITORY = caido/ai-ops.
Result: when the agent omits repo, the PR targets caido/ai-ops. When the agent explicitly passes repo: "caido/proxy-frontend", the server rejects it:
Repository 'caido/proxy-frontend' is not in the allowed-repos list. Allowed: caido/ai-ops
Meanwhile, GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG (conclusion job) has the full config:
{"create_pull_request":{"base_branch":"dev","draft":true,"fallback_as_issue":false,"max":1,"max_patch_size":1024,"reviewers":["corb3nik"],"target-repo":"caido/proxy-frontend","title_prefix":"[refactor] "}}
Analysis
There are two separate config generation paths in the compiler, and only one includes target-repo:
Config path 1: config.json (agent job MCP server) — BROKEN
Generated by generateSafeOutputsConfig() in pkg/workflow/safe_outputs_config_generation.go (line 232-240):
if data.SafeOutputs.CreatePullRequests != nil {
safeOutputsConfig["create_pull_request"] = generatePullRequestConfig(
data.SafeOutputs.CreatePullRequests.Max,
1,
data.SafeOutputs.CreatePullRequests.AllowedLabels,
data.SafeOutputs.CreatePullRequests.AllowEmpty,
data.SafeOutputs.CreatePullRequests.AutoMerge,
data.SafeOutputs.CreatePullRequests.Expires,
)
}
This calls generatePullRequestConfig() in pkg/workflow/safe_outputs_config_generation_helpers.go (line 135-155), which only passes max, allowed_labels, allow_empty, auto_merge, and expires. It does not pass target-repo, allowed-repos, base_branch, draft, reviewers, title_prefix, or fallback_as_issue.
Config path 2: GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG (conclusion job) — CORRECT
Generated by the handler config builder in pkg/workflow/compiler_safe_outputs_config.go (line 346-368), which uses newHandlerConfigBuilder() and includes all fields:
builder := newHandlerConfigBuilder().
AddIfNotEmpty("target-repo", c.TargetRepoSlug).
AddStringSlice("allowed_repos", c.AllowedRepos).
// ... all other fields
Why close_issue works but create_pull_request doesn't
Compare: close_issue (line 225) uses generateTargetConfigWithRepos() which includes target-repo and allowed_repos. But create_pull_request (line 232) uses generatePullRequestConfig() which does not.
Secondary issue: misleading repo parameter description
In pkg/workflow/js/safe_outputs_tools.json (line 247-249), the repo parameter says:
"If omitted, uses the repository at the workspace root."
This is incorrect. When target-repo is configured, omitting repo defaults to the target-repo value — not the workspace root. The getDefaultTargetRepo() function in actions/setup/js/repo_helpers.cjs (line 101) checks config["target-repo"] first. This misleading description causes agents to explicitly pass repo with the wrong value.
Implementation Plan
1. Fix generatePullRequestConfig() to include target-repo and related fields
File: pkg/workflow/safe_outputs_config_generation_helpers.go
Change: Refactor generatePullRequestConfig() to accept the full CreatePullRequestsConfig struct (or at minimum the SafeOutputTargetConfig embedded in it) and include target-repo, allowed_repos, base_branch, draft, reviewers, title_prefix, and fallback_as_issue in the generated config.
The simplest approach is to follow the pattern used by close_issue: use generateTargetConfigWithRepos() as the base and add the PR-specific fields on top.
Implementation sketch:
func generatePullRequestConfig(prConfig *CreatePullRequestsConfig, defaultMax int) map[string]any {
additionalFields := make(map[string]any)
if len(prConfig.AllowedLabels) > 0 {
additionalFields["allowed_labels"] = prConfig.AllowedLabels
}
if prConfig.AllowEmpty != nil && *prConfig.AllowEmpty == "true" {
additionalFields["allow_empty"] = true
}
if prConfig.AutoMerge != nil && *prConfig.AutoMerge == "true" {
additionalFields["auto_merge"] = true
}
if prConfig.Expires > 0 {
additionalFields["expires"] = prConfig.Expires
}
if prConfig.BaseBranch != "" {
additionalFields["base_branch"] = prConfig.BaseBranch
}
if prConfig.Draft {
additionalFields["draft"] = true
}
if len(prConfig.Reviewers) > 0 {
additionalFields["reviewers"] = prConfig.Reviewers
}
if prConfig.TitlePrefix != "" {
additionalFields["title_prefix"] = prConfig.TitlePrefix
}
if prConfig.FallbackAsIssue != nil {
additionalFields["fallback_as_issue"] = *prConfig.FallbackAsIssue
}
return generateTargetConfigWithRepos(
prConfig.SafeOutputTargetConfig,
prConfig.Max,
defaultMax,
additionalFields,
)
}
2. Update the call site in generateSafeOutputsConfig()
File: pkg/workflow/safe_outputs_config_generation.go (line 232-240)
Change: Update the call to pass the full config struct:
From:
safeOutputsConfig["create_pull_request"] = generatePullRequestConfig(
data.SafeOutputs.CreatePullRequests.Max,
1,
data.SafeOutputs.CreatePullRequests.AllowedLabels,
data.SafeOutputs.CreatePullRequests.AllowEmpty,
data.SafeOutputs.CreatePullRequests.AutoMerge,
data.SafeOutputs.CreatePullRequests.Expires,
)
To:
safeOutputsConfig["create_pull_request"] = generatePullRequestConfig(
data.SafeOutputs.CreatePullRequests,
1,
)
3. Fix the repo parameter description in the tool definition
File: pkg/workflow/js/safe_outputs_tools.json (line 247-249)
Change: Update the repo property description from:
"description": "Target repository in 'owner/repo' format. Required when changes are in a subdirectory checkout (e.g., 'repos/repo-a/'). Must be in the allowed-repos list. If omitted, uses the repository at the workspace root."
To:
"description": "Target repository in 'owner/repo' format. For multi-repo workflows where the target repo differs from the workflow repo, this must match a repo in the allowed-repos list or the configured target-repo. If omitted, defaults to the configured target-repo (from safe-outputs config), NOT the workflow repository. In most cases, you should omit this parameter and let the system use the configured default."
4. Dynamically enrich tool descriptions when target-repo is configured
File: actions/setup/js/safe_outputs_tools_loader.cjs
Change: In registerPredefinedTools() (line 94), before registering the create_pull_request tool, check if the handler config has a target-repo value. If so, append to the tool's description:
" Note: This workflow is configured to create pull requests in '{target-repo}'. You do not need to specify the repo parameter."
And append to the repo parameter description:
" Configured default: '{target-repo}'."
Implementation sketch:
function registerPredefinedTools(server, tools, config, registerTool, normalizeTool) {
tools.forEach(tool => {
if (Object.keys(config).find(configKey => normalizeTool(configKey) === tool.name)) {
let toolToRegister = tool;
if (tool.name === "create_pull_request" && config.create_pull_request) {
const targetRepo = config.create_pull_request["target-repo"];
if (targetRepo) {
toolToRegister = JSON.parse(JSON.stringify(tool));
toolToRegister.description += ` Note: This workflow is configured to create pull requests in '${targetRepo}'. You do not need to specify the repo parameter.`;
if (toolToRegister.inputSchema?.properties?.repo) {
toolToRegister.inputSchema.properties.repo.description += ` Configured default: '${targetRepo}'.`;
}
}
}
registerTool(server, toolToRegister);
return;
}
// ... rest of function unchanged
});
}
5. Add a hint to the error message for common cross-repo mistakes
File: actions/setup/js/safe_outputs_handlers.cjs (around line 205)
Change: After resolveAndValidateRepo() fails, if the rejected repo equals GITHUB_REPOSITORY and target-repo is configured to a different value, append a hint:
if (!repoResult.success) {
let error = repoResult.error;
const owningRepo = process.env.GITHUB_REPOSITORY;
if (entry.repo === owningRepo && defaultTargetRepo && defaultTargetRepo !== owningRepo) {
error += ` Hint: This workflow runs in '${owningRepo}' but is configured to target '${defaultTargetRepo}'. Omit the 'repo' parameter to use the configured target, or pass repo: '${defaultTargetRepo}'.`;
}
return { content: [{ type: "text", text: JSON.stringify({ result: "error", error }) }], isError: true };
}
6. Add tests
File: pkg/workflow/safe_outputs_config_generation_test.go
- Test that
generatePullRequestConfig includes target-repo when set
- Test that
generatePullRequestConfig includes allowed_repos when set
- Test that
generatePullRequestConfig includes base_branch, draft, reviewers, title_prefix
- Test backward compatibility: config without
target-repo still works
File: actions/setup/js/safe_outputs_handlers.test.cjs
- Test hint message when agent passes owning repo but target-repo differs
- Test that
repo defaults to target-repo when omitted
File: actions/setup/js/safe_outputs_tools_loader.test.cjs
- Test dynamic description enrichment when
target-repo is configured
- Test no modification when
target-repo is absent
7. Validation and verification
- Run
make fmt to format Go files
- Run
make fmt-cjs to format JavaScript files
- Run
make lint-cjs to validate JavaScript files
- Run
make build to rebuild (required since safe_outputs_tools.json is embedded via //go:embed)
- Run
make recompile to recompile all workflows
- Verify the compiled
config.json in a lock.yml includes target-repo
- Run
make agent-finish before committing
Follow-up Guidelines
- Use console formatting from
pkg/console for any Go CLI output changes
- Follow error message style guide: "[what's wrong]. [what's expected]. [example]"
- The
safe_outputs_tools.json is embedded via //go:embed — make build is required after changes
- JavaScript files in
actions/setup/js/ are the source of truth and are copied at runtime
- The
config.json naming convention uses hyphens (target-repo) to match frontmatter YAML; allowed_repos uses underscores to match JavaScript handler expectations — this inconsistency is intentional (see comment in safe_outputs_config_generation_helpers.go line 168)
Summary
When a workflow runs in repo A but configures
safe-outputs.create-pull-request.target-repoto repo B, thetarget-repovalue is correctly compiled intoGH_AW_SAFE_OUTPUTS_HANDLER_CONFIG(used by the conclusion job) but is missing from theconfig.jsonfile (used by the MCP server during the agent job). This causes the MCP server to default toGITHUB_REPOSITORY(the owning repo), making cross-repo PRs impossible — the agent cannot target the configured repo.Reproduction
Workflow file (
.github/workflows/proxy-frontend-refactor.mdincaido/ai-ops):Expected behavior: The MCP server's
config.jsonincludes"target-repo":"caido/proxy-frontend". The agent can omitrepoand the default resolves tocaido/proxy-frontend. Passingrepo: "caido/proxy-frontend"explicitly also works.Actual behavior: The compiled
config.jsonis:{"create_pull_request":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}}No
target-repo, nobase_branch, nodraft, noreviewers, notitle_prefix. The MCP server'sgetDefaultTargetRepo()falls back toGITHUB_REPOSITORY=caido/ai-ops.Result: when the agent omits
repo, the PR targetscaido/ai-ops. When the agent explicitly passesrepo: "caido/proxy-frontend", the server rejects it:Meanwhile,
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG(conclusion job) has the full config:{"create_pull_request":{"base_branch":"dev","draft":true,"fallback_as_issue":false,"max":1,"max_patch_size":1024,"reviewers":["corb3nik"],"target-repo":"caido/proxy-frontend","title_prefix":"[refactor] "}}Analysis
There are two separate config generation paths in the compiler, and only one includes
target-repo:Config path 1:
config.json(agent job MCP server) — BROKENGenerated by
generateSafeOutputsConfig()inpkg/workflow/safe_outputs_config_generation.go(line 232-240):This calls
generatePullRequestConfig()inpkg/workflow/safe_outputs_config_generation_helpers.go(line 135-155), which only passesmax,allowed_labels,allow_empty,auto_merge, andexpires. It does not passtarget-repo,allowed-repos,base_branch,draft,reviewers,title_prefix, orfallback_as_issue.Config path 2:
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG(conclusion job) — CORRECTGenerated by the handler config builder in
pkg/workflow/compiler_safe_outputs_config.go(line 346-368), which usesnewHandlerConfigBuilder()and includes all fields:Why
close_issueworks butcreate_pull_requestdoesn'tCompare:
close_issue(line 225) usesgenerateTargetConfigWithRepos()which includestarget-repoandallowed_repos. Butcreate_pull_request(line 232) usesgeneratePullRequestConfig()which does not.Secondary issue: misleading
repoparameter descriptionIn
pkg/workflow/js/safe_outputs_tools.json(line 247-249), therepoparameter says:This is incorrect. When
target-repois configured, omittingrepodefaults to thetarget-repovalue — not the workspace root. ThegetDefaultTargetRepo()function inactions/setup/js/repo_helpers.cjs(line 101) checksconfig["target-repo"]first. This misleading description causes agents to explicitly passrepowith the wrong value.Implementation Plan
1. Fix
generatePullRequestConfig()to includetarget-repoand related fieldsFile:
pkg/workflow/safe_outputs_config_generation_helpers.goChange: Refactor
generatePullRequestConfig()to accept the fullCreatePullRequestsConfigstruct (or at minimum theSafeOutputTargetConfigembedded in it) and includetarget-repo,allowed_repos,base_branch,draft,reviewers,title_prefix, andfallback_as_issuein the generated config.The simplest approach is to follow the pattern used by
close_issue: usegenerateTargetConfigWithRepos()as the base and add the PR-specific fields on top.Implementation sketch:
2. Update the call site in
generateSafeOutputsConfig()File:
pkg/workflow/safe_outputs_config_generation.go(line 232-240)Change: Update the call to pass the full config struct:
From:
To:
3. Fix the
repoparameter description in the tool definitionFile:
pkg/workflow/js/safe_outputs_tools.json(line 247-249)Change: Update the
repoproperty description from:To:
4. Dynamically enrich tool descriptions when
target-repois configuredFile:
actions/setup/js/safe_outputs_tools_loader.cjsChange: In
registerPredefinedTools()(line 94), before registering thecreate_pull_requesttool, check if the handler config has atarget-repovalue. If so, append to the tool'sdescription:And append to the
repoparameter description:Implementation sketch:
5. Add a hint to the error message for common cross-repo mistakes
File:
actions/setup/js/safe_outputs_handlers.cjs(around line 205)Change: After
resolveAndValidateRepo()fails, if the rejected repo equalsGITHUB_REPOSITORYandtarget-repois configured to a different value, append a hint:6. Add tests
File:
pkg/workflow/safe_outputs_config_generation_test.gogeneratePullRequestConfigincludestarget-repowhen setgeneratePullRequestConfigincludesallowed_reposwhen setgeneratePullRequestConfigincludesbase_branch,draft,reviewers,title_prefixtarget-repostill worksFile:
actions/setup/js/safe_outputs_handlers.test.cjsrepodefaults totarget-repowhen omittedFile:
actions/setup/js/safe_outputs_tools_loader.test.cjstarget-repois configuredtarget-repois absent7. Validation and verification
make fmtto format Go filesmake fmt-cjsto format JavaScript filesmake lint-cjsto validate JavaScript filesmake buildto rebuild (required sincesafe_outputs_tools.jsonis embedded via//go:embed)make recompileto recompile all workflowsconfig.jsonin a lock.yml includestarget-repomake agent-finishbefore committingFollow-up Guidelines
pkg/consolefor any Go CLI output changessafe_outputs_tools.jsonis embedded via//go:embed—make buildis required after changesactions/setup/js/are the source of truth and are copied at runtimeconfig.jsonnaming convention uses hyphens (target-repo) to match frontmatter YAML;allowed_reposuses underscores to match JavaScript handler expectations — this inconsistency is intentional (see comment insafe_outputs_config_generation_helpers.goline 168)