Skip to content
This text was generated by an LLM (how cgwalters uses LLMs). cgwalters has not reviewed it yet.

Safe outputs

The agent has no forge write credential. What it wants done outside the sandbox it writes down, one JSON object per line: open a pull request with this change, post this comment, or report that it did nothing or lacked a tool. Those are safe outputs. The idea, the format and the type names are gh-aw's, and so is the code that validates and applies them. This page says what is taken from gh-aw, what is added, and what gh-aw's scripts need. How a caller uses the result is in workflow.md; the requirements behind it are R7 and R16.

The run contract specification names the enforced requirements and their checks.

The applying job's SAFE_OUTPUTS_PAT is our equivalent of gh-aw's safe-outputs.github-token for cross-repository safe outputs. See the token mapping and setup for repository bounds, job-token limits and default-branch environment storage.

What is gh-aw's, and why

One script is run as gh-aw ships it: its handlers (safe_output_handler_manager.cjs), which act on the requests. They come from the action github/gh-aw/actions/setup, pinned at commit 462ed682857c4ab64d40f2c85e3738fa27834839 (v0.91.12-7) in every place that uses it. Nothing of gh-aw is copied into this repository. Its collector (collect_ndjson_output.cjs), which validates and sanitizes the requests, is reimplemented as collect, held to it by fixtures gh-aw's collector wrote at that commit. The rules an agent is held to must be the ones it can check before its session ends, and gh-aw's collector cannot run on the agent's machine: so handback check runs collect, and so does the check job.

gh-aw's collector adds a report_incomplete item of its own to a result holding nothing but missing_tool and missing_data, or nothing at all; collect does not. No run may hand one back: check leaves out exactly that last item, refuses any other, and apply's handlers get only the requests check accepted.

Both run with gh-aw's safe-outputs.urls set to allowed-or-code-region. Its default redacts every URL that is not https:// to an allowed domain, in code blocks too, and a non-https one up to the next space, so build output was lost: Cargo's path+file:// package ids took the JSON after them with them (#401). Now URLs in inline code and code blocks are kept, and those in prose are filtered. gh-aw's idea of code is not exactly GitHub's (an HTML block around a fence, escaped backticks, a span across a blank line), which no longer matters for https links: see below.

An https:// link to any public host is kept as written, in prose and code, for every output type (#918). The filter cost reports their sources and stopped no leak: the sandbox holds no credential worth one, its reads are open to any host anyway, and GitHub fetches images through its own proxy. The pinned sanitizer has no setting that keeps every https link, and no wildcard for every domain, so policy gives it, as safe-outputs.allowed-domains (GH_AW_ALLOWED_DOMAINS to the handlers; collect keeps the same), every top-level domain in IANA's list, whose subdomains it keeps. just refresh-top-level-domains updates the list; a link to a domain newer than it, to an address or to a host of one label is still redacted, as is a non-https URL in prose. gh-aw's other sanitizing stays: mentions, bot triggers, HTML and link titles. A bounds file's top-level allowed_domains (from #910) is accepted and ignored. gh-aw's redaction takes a Markdown link's closing parenthesis with it, posting [text](http://host/path) as [text]((host/redacted), so check makes that text (host/redacted) before apply. agentic-job handback check notes, for each request, the hosts whose links would be redacted, before the session ends.

Secrets are not filtered by shape either, beyond redacting logs. run refuses to upload anything while the hand-back or a file that could not be redacted holds a value the run itself holds, its run token among them: that one would be a real leak. Check and run refuse a PEM private key anywhere in the outputs, code included, since one cannot be a harmless fixture. Any other credential-shaped string (a ghp_, sk- or AKIA token, a JWT) is most likely a test fixture, and lost runs all their work when it was refused (#720): it is kept, and reported as a warning by file, line and pattern, never by the string, in check's report and summary and in the run's summary. agentic-job handback check runs the scan of agentic-job handback scan --base BASE_SHA too, which lists the same by file and line in the change's diff and in out/, and exits 1 only for a private key or a praxis-run- broker run token the change adds or out/ holds: run refuses its own by value, and the agent cannot tell it from another by shape. Other run secrets (an api-key run's token, the MCP proxy token) have no listed shape, so a hand-back holding one is refused only at upload. The final refusal names the file and line too, never the string: in the job's log, its summary and, through the run action's secret-at output, the status comment, since such a run uploads nothing else to look in.

One file is derived from gh-aw: safe-outputs/validation.json, the rules of each type. gh-aw's compiler writes those into the workflows it generates and publishes them nowhere else, so validation.mjs reads them out of gh-aw's own compiled workflows, and CI fails when the file is not what the pinned commit gives. The same script reads, out of the handlers' code at that commit, the limits they hold a request to at apply and their errors: a comment's length and links (E006, E008), counted again with the footer gh-aw adds and apply's own note and marker, and the labels of an issue or of add_labels (E003). It fails on a limit check in the handlers it does not list. agentic-job handback check refuses a request over one in the session, in the handler's words, so the agent can still change it; apply refused a comment of 58 links after the session, and it was lost whole (#963). It also refuses a missing_data with no reason, which gh-aw takes and the status comment can only quote as "none given" (#1102); check does not, as it would refuse a change handed back with it.

The types a run can be allowed are create_pull_request, create_issue, create_discussion, add_comment, close_issue, add_labels, remove_labels, update_issue, update_pull_request, mark_pull_request_as_ready_for_review, create_check_run, update_project, push_to_pull_request_branch, add_reviewer, assign_to_user, close_pull_request, link_sub_issue, hide_comment, create_pull_request_review_comment, reply_to_pull_request_review_comment, resolve_pull_request_review_thread, noop, missing_tool and missing_data.

Updating project fields

update_project admits exact Projects v2 URLs, exact field names, and a count:

[outputs.update_project]
max = 3
projects = ["https://github.com/orgs/example/projects/1"]
fields = ["Status", "Turn", "Run", "Result"]

Both lists must be explicit and nonempty. Each request includes project, target_repo equal to the run's exact repo, content_type: "issue", a positive integer content_number, and a nonempty fields object. Values may be strings or numbers; null/clearing is not supported by the pinned handler. Field options and types must already exist on GitHub. The handler can add the issue to the named project if it is not there. Other repositories, unlisted projects or fields, draft/PR content, aliases, project/schema mutations and temporary IDs are not exposed. Per-type max and total max_outputs count requests, not fields. The whole hand-back is refused when it exceeds either count. This accepts agent-board's documented reconcile plan --emit output without translating its routing keys.

Organization project writes require the caller's apply environment token; see token setup. The repository job token cannot write organization projects, so a call without apply-environment is refused update_project at admission, before an agent runs, and outputs: all leaves it out. The token stays in deterministic apply and, when explicitly supplied, status jobs, never in the proposals producer or check.

Apply also guards the handler's GraphQL client: it refuses implicit field creation and field IDs not read back with an exact allowlisted name. This stops the handler's normalization from writing an unlisted field or creating schema.

Closing issues and changing labels

These operations are bounded to the run's exact repo, admitted by the caller's repository bounds, and their per-type max and the total max_outputs. They do not use output-repo or fall back to the triggering event's issue. Requests must include that explicit repo and a positive integer issue_number for close_issue, or item_number for add_labels and remove_labels (issues or pull requests). Temporary IDs, target aliases and duplicate relationships are refused.

[outputs.close_issue]
max = 2
[outputs.add_labels]
max = 3
allowed = ["triage", "documentation"]
blocked = ["release"]
[outputs.remove_labels]
max = 2
allowed = ["triage", "in-progress"]
blocked = ["release"]

add_labels requires an explicit allowed list; an empty one permits no labels. Labels must be plain strings, matching that list case-insensitively, and not in blocked. To keep the handler's final API payload identical to the checked labels, names must be 1–64 ASCII characters: letters, digits, spaces or - _ . / :, with no leading/trailing whitespace or leading -. Unicode and other punctuation are refused rather than sanitized. max bounds both requests and labels within each request; requests exceeding either limit are refused, not partially applied. The handler does not create missing labels. Create the labels first. remove_labels is off unless listed in bounds. Its explicit allowed list stops an agent from removing authorization or automation labels outside the caller's policy. It uses the same name restrictions, case-insensitive exact allow/block matching, repository scope and request/label caps as add_labels. It requires a nonempty labels array of strings; an empty allowlist permits no removals. Apply removes each checked label separately; an absent label is a no-op. Reapplying a request is therefore safe. No label definitions are deleted. The pinned handler records other API errors as failedLabels but can report success after partial removal; callers should inspect the apply log when a label transition matters, rather than treating job success as atomic completion. For example, {"type":"close_issue","repo":"OWNER/REPO","issue_number":7,"state_reason":"not_planned","body":"Overtaken by #9."} posts the body on issue 7 and closes it. The body is required, so whoever watches an issue learns why it was closed; state_reason is completed or not_planned, completed when left out. Apply refuses the run, before it applies anything, when a close_issue names a pull request (they share numbers, and the handler would close one through the issues API) or an issue transferred to another repository. A re-run finds the comment by its name, as for a pull request's closing comment, and closes the issue if the attempt stopped in between, but not one reopened since. These operations need issue-write access in apply (PR labels can instead use pull-request write access). No producer or check token gains write access.

Updating issues and pull requests

update_issue and update_pull_request change only the fields their bounds list, and check refuses a request holding any other key. The pinned handlers cannot be relied on for this: update_issue ignores allow_title and allow_status, and a request's draft and update_branch override update_pull_request's configuration.

[outputs.update_issue]
max = 1
fields = ["body"] # of "title", "body", "status"
[outputs.update_pull_request]
max = 1
fields = ["title", "body"] # of "title", "body", "draft", "update_branch"

fields must be explicit and nonempty; organization bounds narrow it. A listed body also admits operation (append, prepend, replace or replace-island, which keeps one section a re-run replaces). An issue's labels, assignees and milestone cannot be listed: labels would replace every label past add_labels's bounds, and nothing bounds the others. Requests name the run's exact repo and a positive integer issue_number, or pull_request_number; aliases and temporary IDs are refused, as above. GitHub numbers issues and pull requests together, so an update_issue can edit a pull request's title, body or state through the issues API.

Apply opens a pull request as a draft unless the bounds set ready_when_complete and the work is complete (ready or draft). An agent taking one out of draft, with draft: false or mark_pull_request_as_ready_for_review, is refused unless the bounds list draft or that type; no shipped bounds file does. Mark-ready requests take only repo, pull_request_number and the reason the handler posts as a comment. update_branch merges the base into the pull request's branch with the apply credential, which needs push access to it.

Apply needs issues: write for update_issue, and pull-requests: write for the other two. No producer or check token gains write access.

Ready or draft

By default every pull request is a draft, so "draft" says nothing about the work. A bounds file can make it mean "a checkpoint, not finished":

[outputs]
create_pull_request = { max = 1, ready_when_complete = true }
# and for a continuation of a pull request:
# push_to_pull_request_branch = { max = 1, branches = ["agent-run-*"], ready_when_complete = true }

Organization bounds can only turn it off. With it, apply opens a pull request ready for review when the work is complete, and as a draft when it is not: when the run was stopped at a limit (exit state 124 or 3, applied only with apply-partial), or when the pull request's body holds a checkpoint. The agent writes one when it nears a limit, as the hand-back notice says, or knows the work is unfinished; a pull request made up from an outcome whose stopped_early is set, or that has no summary, gets one too. A checkpoint is shown, not hidden:

```checkpoint
{"reason": "budget", "done": "the parser", "left": "its tests", "next": "add tests for the new cases"}
```

reason is budget, runtime, cap or incomplete; the other three are nonempty strings. The agent is also asked to keep a checkpoint in its outcome from the start (harness); while that is not "complete": true, the pull request made up from the outcome, and the agent's own request if its body or message holds no checkpoint, end with it as incomplete, each field cut to 400 characters. check refuses the hand-back for a malformed checkpoint, a second one, a line starting like the fence with more on it, or one over 8 KiB, rather than take it for complete work. Apply records the reason in applied.json's checkpoint: the exit state's (runtime for 124, cap for 3, which does not tell a request, task or spending limit apart) when the run was stopped, the checkpoint's otherwise, and null for complete work.

A push to a pull request (a continuation) works the same way, its commit message holding the checkpoint. Once the push is verified, apply marks the pull request ready for complete work and converts it to a draft at a checkpoint, with the apply credential, which then needs pull-request write access. It is the one place apply takes a pull request out of draft, and needs no bound of its own: what decides is the exit state and check's report, not a request, and the review job remains the merge gate. A run stopped at a limit still pushes nothing (RC-020). A run that ends with neither complete work nor a checkpoint, nor any output its run requires, fails as before.

What the checks stop is unfinished work opening ready for review, where a reader or a poller would take it for finished: a stopped run cannot claim to be complete, and a checkpoint check cannot read is refused, not ignored.

Requesting reviewers and assigning users

Both notify whoever they name, so an agent could otherwise page anyone with access to the repository. Each is off unless listed in bounds, and then needs a nonempty allowed list of logins; a bounds file without one is refused.

[outputs.add_reviewer]
max = 1
allowed = ["octocat"]
[outputs.assign_to_user]
max = 1
allowed = ["octocat", "hubot"]

A request names the run's exact repo and a positive integer pull_request_number (add_reviewer) or issue_number (assign_to_user), with a nonempty reviewers or assignees array. check refuses the whole hand-back if a login is not in allowed exactly as written (the pinned handlers compare logins case-sensitively and would otherwise drop it silently), or if a request names more logins than max. Target aliases, team_reviewers and the singular assignee are refused. GitHub also accepts a pull request's number as issue_number. Apply does not unassign current assignees. The pinned add_reviewer handler requests Copilot's review bot for the login copilot; list it only to allow that. add_reviewer needs pull-request write access in apply, and assign_to_user issue write access; the listed users must already be able to review or be assigned. No producer or check token gains write access.

Closing pull requests and linking sub-issues

close_pull_request and link_sub_issue are off unless listed in bounds, and have the same repository scope and caps as the issue operations above: an explicit repo that is the run's, and positive integers, never temporary IDs, target aliases or the triggering event's item. Without these rules an agent could close or link items in another repository the apply token reaches, or let the handler pick the pull request or issue the run was started from.

[outputs.close_pull_request]
max = 1
[outputs.link_sub_issue]
max = 2

{"type":"close_pull_request","repo":"OWNER/REPO","pull_request_number":7,"body":"Superseded by #8."} posts body on pull request 7 and closes it; gh-aw's collector requires the body. A re-run of apply finds that comment by its hidden name and posts it only once. {"type":"link_sub_issue","repo":"OWNER/REPO","parent_issue_number":5,"sub_issue_number":6} makes issue 6 a sub-issue of issue 5; the handler skips a sub-issue that already has a parent. Apply needs pull-request write access for the first and issue write access for the second.

Posting a check run

An agent-chosen success could satisfy a required check, so create_check_run is off unless listed in bounds, and its bounds pin the check run's name:

[outputs.create_check_run]
max = 1
name = "agent verdict"

The name is 1–64 characters, without control characters or surrounding whitespace. gh-aw's handler takes it from apply's configuration, never from the request; check refuses a request that names another one, and the success conclusion. Other conclusions remain the agent's, and GitHub counts neutral and skipped as passing a required check: pin a name no ruleset or branch protection requires. When two bounds files both allow the type, their names must be the same.

A request must give a positive integer pull_request_number, and no alias for it; for example {"type":"create_check_run","pull_request_number":7,"conclusion":"neutral","title":"Verdict","summary":"..."}. The handler posts on that pull request's current head in the calling repository, so apply refuses a run whose repo is another repository. Which pull request in it is the agent's choice. GitHub lets only an app create check runs: apply needs checks: write for the job token, and fails with SAFE_OUTPUTS_PAT set to a user's token. If the pinned name is the calling workflow's name, the handler appends "(Result)" to it.

Starting a discussion

create_discussion is off unless listed in bounds, and its bounds pin the category, by name, slug or node ID:

[outputs.create_discussion]
max = 1
category = "Agent reports"

The category is 1–64 characters, without control characters or surrounding whitespace; when two bounds files both allow the type, theirs must be the same. gh-aw's handler takes a request's own category over its configuration's, so check refuses a request naming any other than the exact pin, and one naming another repository. The handler posts in Announcements, or the first category, when it does not find the one it was given; apply refuses to post in a category not read back under the pin, so a pin that names no category in the repository fails the job rather than posting elsewhere. Pin a category whose readers expect agent posts.

The discussion goes to the run's repo. It is not turned into an issue when discussions are off or the token cannot post one, and older discussions are not closed. The collector drops a request's labels and temporary_id. Apply needs discussions: write for the job token, or a SAFE_OUTPUTS_PAT that can post discussions there. A re-run of apply posts a discussion again: unlike an issue, it is not looked for under its name first.

Hiding a comment

hide_comment minimizes a comment on the run's triggering issue or pull request. That is moderating what other people wrote, a maintainer's included: a reader sees it folded under the reason, and an agent misled by the item's text could fold the comments that argue with it. It is off unless listed in bounds, which pin the reasons it may give:

[outputs.hide_comment]
max = 3
allowed_reasons = ["SPAM", "OFF_TOPIC"]

The reasons are those of gh-aw's collector, SPAM, ABUSE, OFF_TOPIC, OUTDATED, RESOLVED and LOW_QUALITY; the list is nonempty, since the handler takes an empty one for any reason, and two bounds files keep the reasons they share. Check refuses a request for another reason (none given is SPAM, as the handler takes it), one naming another repository than the item's, and every request of a run without one item: the event's, or a numeric comment-target, which takes the event's place, never *. The item's repository is a comment's: item-repo, else output-repo, else the calling one; an event-triggered run whose output-repo is another repository is refused the type before its agent starts, since the event's item is in the calling one. Only the handler can look a comment up, so it is given that item's number as its target and the repository as target-repo, as gh-aw's target: triggering would from the event, and refuses a comment anywhere else; a run without one item gets no handler. Apply needs issues: write for an issue's comments, or pull-requests: write for a pull request's, for the job token, or a SAFE_OUTPUTS_PAT that can moderate comments there. A re-run hides the comments again, including one a maintainer unhid in between.

Review comments on the pull request

create_pull_request_review_comment comments on a line of the run's triggering pull request's diff, reply_to_pull_request_review_comment replies to a review comment there, and resolve_pull_request_review_thread resolves a thread. They give a review inline comments without a verdict: the comments go in one review of event COMMENT, and submit_pull_request_review is not offered. Each is off unless listed in bounds, with a max and nothing else:

[outputs]
create_pull_request_review_comment = { max = 10 }
reply_to_pull_request_review_comment = { max = 5 }
resolve_pull_request_review_thread = { max = 5 }

They are held to the run's item as hide_comment is, for the same reason: an agent misled by text it read could otherwise comment on, or resolve the threads of, another pull request. Check refuses a request whose pull_request_number is not the item's, one naming another repository than the item's, and every request of a run without one item; policy refuses the types to an event-triggered run whose output-repo is another repository. Apply gives each handler the item's number as its target and its repository as target-repo, so a comment goes on that pull request's diff, and the resolve handler looks the thread up and refuses one elsewhere. The reply handler does not look its comment up, so apply looks it up first and refuses the run, before applying anything, when it is on another pull request. An item that is an issue has no diff: the handlers fail there.

Resolving a thread is the agent's to decide: a ruleset requiring conversation resolution before merging counts a thread the agent resolved, a maintainer's included. Bounds list the type only where that is wanted. Apply needs pull-requests: write for the job token, or a SAFE_OUTPUTS_PAT that can review the pull request. A re-run posts the comments and replies again, and leaves a thread resolved before as it is.

Pushing to a pull request's branch

Replacing the complete series

The threat to stop before allowing a history rewrite is overwriting another writer's advance. A second threat is hiding a forbidden intermediate edit behind a later revert. Replacement therefore uses an exact head lease and checks every commit's patch, not just the final tree.

Replacement is off by default. The caller may add replacement_max_commits = 10 to [outputs.push_to_pull_request_branch] (maximum 50). Organization bounds intersect this ceiling; zero in either file forbids replacement. The ordinary one-commit push remains unchanged. The same head repository and branch checks apply; no write token is given to the producer or checker, and a partial run cannot replace history.

The producer runs agentic-job handback replace --base BASE_SHA [RANGE] in its checkout, alongside its push_to_pull_request_branch request. The range defaults to BASE_SHA..HEAD. The command generates the mails itself, preserving final newlines, and tests the series with git am in an independent scratch repository before atomically writing out/replacement.json under its home. A failed validation leaves any existing handback untouched. Do not construct the mails or JSON by hand: shell command substitution strips final newlines and can corrupt a patch. Capture repairs missing final newlines with a warning; check still refuses unrepaired mails as a backstop. This helper grants no additional authority: the existing policy and independent check/apply limits remain unchanged. An external proposals artifact names the file replacement.json. Its strict JSON shape is:

{"base_commit":"BASE_SHA","commits":["FIRST_MAIL","SECOND_MAIL"]}

BASE_SHA must be the base branch head read by the policy job (the run's brief names it), or the current head of the PR's base branch resolved by trusted apply. Policy resolves the branch ref, not the PR's possibly stale base SHA, and run fetches that snapshot into the producer checkout. Each string is one plain git format-patch --stdout --no-signature --no-renames -1 COMMIT mail with a valid Date: header, no cover letter and no base header. This is the full replacement series, including any original commits to keep, rather than a fixup on the old head. It can reword, squash or rebase onto that admitted base snapshot. Commit messages, authors and existing trailers are preserved; run does not append its ordinary single-commit trailers to these mails. Do not include base.json or an aw-*.patch alongside a replacement. Run copies the series without resetting the producer's checkout; it does not collect additional uncommitted changes on this path.

The entire JSON fits max_patch_bytes. Check bounds the number of mails, checks each with the ordinary path/type rules, and bounds the union of changed paths by max_patch_files. Empty commits and multiple mails in a string are refused. Apply builds only a linear series using plain git am on the trusted base and compares every commit's actual paths to check's list. No producer repository, bundle, hooks or configuration is imported. A fixed committer and author-derived committer dates make the resulting head stable. Apply pushes it with --force-with-lease=refs/heads/BRANCH:ORIGINAL_HEAD. An exact already-applied head is a no-op; a different head, even with the same tree, refuses the replacement. The final remote head is verified.

The policy base remains an admitted snapshot if the branch advances. A fix run may also fetch a newer head and rebase onto it; apply independently reads the PR's base branch and its current head, and refuses any other base before materializing or pushing. Check validates the claimed full commit ID, not its forge membership. A head that moves again before apply resolves it requires a new proposal or the policy snapshot. This prevents an arbitrary producer commit from becoming the root of the replacement series.

push_to_pull_request_branch lets a follow-up run fix an open pull request: its change goes onto that pull request's branch as one commit, not into a new pull request. It goes to the pull request's head repository, whoever opened the pull request, where the identity apply holds may push: its own fork (pull requests come from a fork), another fork whose owner gave it write access, or the target repository itself. The target's CI runs a fork's commit as it ran the pull request's first one: as a fork's, with a read-only token, no secrets and no OIDC token. The head repository's own workflows are different: apply turns Actions off only in its own fork, and elsewhere the push and pull_request workflows of the target or of the other fork run the agent's commit with whatever secrets, write token and OIDC access they hold (#340, #430). So apply pushes to any head repository but its own fork only when that repository's default branch has the file .github/agentic-job/accept-owner-branches naming that repository (owner/repo) on a line of its own. Write access given to the account is not that acceptance, nor is a file a fork inherited from the target it was made or synced from, which names the target. It is the head repository's own decision, not a caller's: only a commit to its default branch adds the file, which apply never pushes. The same file also lets the applying account open new pull requests from branches of a repository it owns, where those branches get the same workflows. Refusing .github keeps the agent from adding workflows there, not from changing the build scripts, tests and local actions the existing ones run. Add it only to a repository whose workflows give a branch nothing an agent is not to have; where that repository is also the caller, keep its apply environment to the default branch, so that a branch's workflows cannot use its token. Any other head repository has to be a fork, and not the calling repository, which holds the run's secrets. "May push" is write access to the head repository: a fork's pull request that lets the target's maintainers edit it is not enough. It is off unless a bounds file lists it, with globs of the branches a push may go to:

[outputs.push_to_pull_request_branch]
max = 1
branches = ["agent-run-*", "dispatch/**"]

Unlike bases, the branches globs match case exactly: dispatch/** does not admit DISPATCH/x, since a forge keeps a branch name's case. The branch of a pull request apply opened is the caller's branch-prefix followed by agent-run-RUNID.

The caller names the pull request by number (push-item, the fix profile of dispatch); the agent never does. The policy job reads that pull request with its read-only token and refuses it unless it is open, its base is in the run's repository and its head repository still exists. The outputs must be applied there (output-repo empty or that repository), and apply must hold SAFE_OUTPUTS_PAT from an apply-environment: the job token names no user. Policy refuses anything else before the agent runs. Its branch must match branches (bases does not apply), and policy.json records the number as the push's target and the head commit the branch was at as its head. A run with a push has no create_pull_request: it hands back one patch.

The run clones the target's default branch, fetches head by its commit (the forge serves a fork pull request's head from the target too) and starts there:

  • the run refuses to start when the checkout is not at head;
  • check refuses a hand-back whose base is not head, a push line naming any branch but the pull request's, and the same patch rules as a pull request (protected files, top-level dot-folders, size and file count), including the same exact-repository .github exemption as a new pull request;
  • apply reads the pull request again with its own token, before anything is forked, fetched or pushed, and refuses unless it is open, from the policy's branch, and that branch is neither its head repository's default branch nor protected nor one a ruleset applies to (any rule: a ruleset on every branch leaves nothing to push to); it refuses a head repository its identity cannot push to, saying so, one that is neither a fork of the target nor the target, the calling repository unless it is the target, a branch of the target itself that the target does not accept, and the .github exemption anywhere but its own fork. For a pull request from that fork it makes or finds the fork and turns its Actions off, as for a new pull request. gh-aw's handler is given the head repository as the only one it may push to, and refuses a pull request from any other;
  • the handler applies the patch on head and pushes one commit without force, with no fallback to a new pull request. Only the .github exemption enables allow_workflows and the matching dot-folder exclusion. A branch that moved since head was read makes the push not a fast-forward, which the forge refuses: the pull request has to be run again. Before the handler runs, apply leaves out a push if the fetched head branch already holds exactly one commit on head with the checked tree. This succeeds as already applied, without pushing again. Apply then fetches the branch back from the head repository and refuses unless it is that one commit on head with the checked tree.

The final check also runs for an already-applied push: a branch moved between the guard and the final fetch still fails. A merge commit, another tree, or the checked tree on another parent does not count as already applied.

A partial run (one stopped at a limit, applied under apply-partial) is not pushed: nothing on a pushed commit would say the work was cut short, so apply fails instead.

Anyone who may push to the head repository, the identity apply holds, and maintainers of the target a fork's pull request lets edit it can move its branch. One of them rewriting it so that head is no longer in it is the case the handler does not refuse: it logs a warning and applies the patch with git am --3way on the branch as it finds it, that push succeeds, and apply's last step fails after the commit is on the branch. The handler does not check the pull request's state; apply does, so a pull request closed before apply runs is not pushed to.

The bounds and the policy

A caller keeps a bounds file in its repository (this repository's): the hosts a clone URL may name, globs of repos and bases, max_outputs, max_patch_bytes, max_patch_files, and under [outputs] each type with its ceiling. agentic-job policy checks one run's request against it (--repo, --clone-url, --base, --kind, --outputs, --max-outputs) and prints policy.json, or every reason for refusing. --outputs all is every type the bounds list, and --max-outputs max their own ceiling. It runs in a job of its own, before the agent's machine starts. A top-level private = true admits a target that is not public, under the conditions of private repositories; it is off by default. A top-level allow_http, empty by default, lists hosts of hosts whose clone URL may be plain http://, for a self-hosted forge on a private network without TLS: anyone on that network can then read and alter the clone, and a private repository's read token crosses it unencrypted. It may not name github.com or a host under it, with or without a port or user, and run lets git use plain HTTP only for a target admitted that way, so no other clone can be downgraded by a redirect. With organization bounds, both files must name the host. A top-level push_repo = "OWNER/NAME" names the fork that Forgejo's apply pushes a pull request's branch to. It may not be of the target's owner, and with organization bounds both files must name it. A top-level forge (github by default, forgejo or gitlab) says where the outputs are applied, and limits [outputs] to the neutral profile off GitHub; with organization bounds, both files must name the same one. A top-level allowed_domains is accepted and ignored: links to every public host are kept (above).

A [memory] table lets a run hand back the agent's notes (below); without it none is handed back, and check refuses one. Its keys, all optional, and their ceilings, which are gh-aw's repo-memory defaults:

[memory]
max_files = 20 # at most 100
max_file_bytes = 16384 # at most 102400 (100 KiB)
max_bytes = 65536 # all files together; at least max_file_bytes, at most 1 MiB
extensions = ["md", "txt", "json"] # from md, txt, json, jsonl, csv

With organization bounds, memory is allowed only where both files allow it, within the smaller of each limit and the extensions both list. Storing the memory (apply) and giving it back to the next run (the agent job) are not implemented yet; both are tracked in #119. Until then the brief does not mention memory.

Some things no bounds file can change. A run hands back one patch, so at most one pull request. A pull request is a draft unless it is complete work under ready_when_complete. An analysis run may not ask for one. gh-aw's default list of protected file names, less README.md, and every top-level dot-folder are protected by default; a bounds file can take names off that list and exempt the .github folder for repositories it names exactly (case-insensitive OWNER/REPO, no globs), and cannot add to the list. No other dot-folder can be exempted: check refuses .husky/, .vscode/, .circleci/ and .tekton/ whatever the policy says. For example, to allow CONTRIBUTING.md and workflow edits in one repository, add this to the bounds file (not to the caller workflow):

[unprotected_files]
repos = ["OWNER/REPO"]
files = ["CONTRIBUTING.md"]
dot_folders = [".github"] # optional, and only ".github"; see the protections below

A bounds file can also protect more paths, in every repository it admits, with a top-level protected list of globs: paths a run must never change, such as a wiki's raw source files. They match as a push's branches do (* within one directory, ** across them, case counting) against the path from the repository root, so raw/** covers everything under raw/ and not wiki/raw/, and **/*.pem misses a .pem file at the root (list *.pem too). A glob only ever adds protection; it cannot take anything off the list above. Check refuses a patch touching a matching path, naming the path and the glob, and the brief tells the agent the globs. With organization bounds, both files' globs apply. A file may list at most 64 globs of at most 256 bytes each, of plain relative components (no leading or trailing /, . or ..); ?, [...] and {a,b} are refused rather than taken literally. The key is top-level, so it goes before the file's first table:

protected = ["raw/**"]

Who the hand-back's commit is by is otherwise the agent machine's to say ([commit] author), so a compromised one could pass a commit off as a maintainer's. A bounds file can pin it, as Name <address> in plain ASCII: commit_author = "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>". run then commits as that author, and check refuses a patch whose From: is anyone else's. Two bounds files that pin different authors refuse every run. A replacement keeps its commits' own authors and is not held to the pin, so bounds that pin an author should leave replacement_max_commits at zero. Whether or not one is pinned, check refuses a mail header given twice and a From:, Subject:, Date: or [PATCH] line at the start of the message, which git am would take over the mail's own.

This does not relax the fixed patch refusals below. New executable files remain refused: add a script without its executable bit and run it with its interpreter. Check refusals appear as run annotations and, when a comment target is available, in the run's conclusion comment.

The exemption does not reach protected file names, git's own files inside .github/ (.github/CODEOWNERS, .github/.git/) or the review's merge policy, .github/agentic-job/merge.toml. Organization and caller bounds must both permit it. It applies to create_pull_request and push_to_pull_request_branch, including replacement series, only for the same listed repositories. A fix push still requires the open PR and head repository/branch checks described above, and is refused the exemption anywhere but apply's own fork. A repository its applying account owns, whose new pull request's branch is pushed to it and not to a fork, cannot have the .github exemption either: apply refuses the pull request. Workflow changes are pushed only to apply's fork, which has Actions turned off, and reach the repository as a fork pull request. Its pull_request CI gets no secrets and a read-only token, not the target repository's write credentials. Operators must configure and enforce pre-merge review and the intended required checks; the exemption does not provide these merge protections automatically. If relying on the trusted workflow ingress check as a merge gate, an administrator must enforce that workflow with a ruleset: a required status name alone can be imitated by PR-owned workflows. The trusted workflow ingress check flags known-bad workflow patterns and new analyzer ignores; it does not prove a workflow safe or replace review. Reviewers must treat changes to the caller's bounds (.github/agentic-job/) or to the workflow that calls agentic-job as policy changes: the exemption lets an agent propose widening its own bounds, which takes effect once merged.

The output check is not the gate against a malicious workflow edit. Before review, the target's pull_request CI can run the edited workflows, choosing their runners (runs-on), matrices and job counts. Fork token restrictions do not protect a self-hosted runner from that code; do not make self-hosted runners reachable by fork pull request workflows. Two additional measures are optional hardening, not prerequisites for the exemption:

  • Requiring workflow approval for all external contributors stops an edited workflow from spending CI resources or choosing runners before a human looks, which matters especially with self-hosted runners.
  • Keeping the apply account (the user of SAFE_OUTPUTS_PAT) outside the repository's and organization's membership and collaborator roles makes that external-contributor approval policy apply to its fork pull requests, since members and collaborators bypass it.

The apply credential needs classic PAT repo and workflow scopes (or a fine-grained token with Contents and Workflows write permissions on the fork) to push workflow files. No agent or check job gains a write credential.

policy.json holds, as safe_outputs, gh-aw's own configuration format: the collector reads that part as it is. The additional unprotected_dot_folders list, on both patch-carrying outputs, is enforced by check. Apply passes it to gh-aw's handler as protected_dot_folder_excludes, keeping only .github whatever the list says, and keeps protect_top_level_dot_folders on, so the handler still refuses every other dot-folder before and after it applies the patch. protected_files_policy stays blocked, so protected basenames remain refused.

gh-aw's per-type target-repo and allowed-repos, and its other protected-files policies (request-review, fallback-to-issue), are deliberately not offered: a bounds file that names one is refused as an unknown key. Destinations stay fixed by the caller (target repositories), never chosen per type or by the agent, and a patch that touches a protected file is refused rather than sent for review or turned into an issue.

A secondary pull request

A [secondary_pull_request] table names one further, fixed repository that a run may propose one pull request to, without making the repositories in repos writable (#1075). It stops an agent from choosing where that pull request goes: the repository, base and branch are the bounds', and the request gives only the title and body. Off by default; bounds without it are unaffected. What the caller's SAFE_OUTPUTS_PAT needs for it is in the workflow guide.

[secondary_pull_request]
repo = "cgwalters-forge/misctool"
base = "main"
branch_prefix = "agentic-job/"
max = 1
max_patch_bytes = 65536
max_patch_files = 10

It is one table, not a list. repo is one exact OWNER/NAME, base one exact branch, and branch_prefix ends in / and forms an unambiguous branch name. max must be 1, and the patch limits have no defaults (at least 1024 bytes and 1 file). Its own [secondary_pull_request.unprotected_files] takes the same keys as the top-level one but may name only repo; nothing is inherited from the top-level exemptions, so protected files and .github stay protected unless exempted here. The top-level protected globs, which only take away, hold for it too. It is for GitHub only.

policy adds it to policy.json only for a branch run that may open its own pull request, and refuses one whose run works on, or applies to, that same repository. Organization bounds keep it only where both files name the same repository, base and prefix, within the tighter budgets. run clones the repository at its base into ~/work/secondary/OWNER/NAME, with no credential (a run that opens a pull request is public). A change there is handed back as its own patch, with secondary-base.json, only for a create_pull_request whose repo names that repository; without one it is dropped with a warning. The request goes on the run's branch under the prefix, and its patch is named as gh-aw's handler looks for it first when a request names a repo (aw-OWNER-NAME-agentic-job-agent-run-ID.patch), never as the run's own. Check counts that request against the secondary's own max (the collector is given the sum of both), and against max_outputs like any other request, so raise that by one to leave the run's own requests their room; it holds it to the secondary's base, to being a draft and to that branch of this run (check --run-id, never an earlier run's branch, whose open pull request it would push to), and its patch to its own base file, budgets and protected files and to the bounds' commit_author. check.yml hands that patch on apart, in secondary/ of its artifact, so that apply's trial of the run's own patch never takes it, and leaves its request out of the requests apply's handlers get, which would refuse it; it uploads policy.json beside them. A request naming any other repository is refused as before.

agentic-job secondary-pull-request --policy FILE --checked DIR --run-id ID --run-url URL is apply's pass for it, which apply.yml runs once gh-aw's handlers have applied everything else, only where check reported a request or a patch for it, with SAFE_OUTPUTS_PAT, and not the job token, in its environment; a staged apply skips it. What it prints, its error: lines included, goes into apply's summary, and a failure fails apply and says so in the status comment. It holds check's report and the patch in secondary/ to the policy again, as the Forgejo apply does: the repository, base, prefix and budgets are the policy's, and the branch must be this run's under the prefix, so a request can choose only the title and body. Then, as apply.yml does for the run's own pull request, it forks the repository into the token's account (never the repository itself, an owner's, or the run's own repository should that be the token's fork of it), waits until git reaches the fork, turns the fork's Actions off and reads that back, pushes the branch to the fork only if it does not exist yet, and opens the pull request as a draft. A refusal or failure is an error: line naming the secondary pull request and a failed exit, with nothing written to the forge for a refusal. It has no recovery as the Forgejo apply has: when the pull request cannot be opened after the push, the error names the pushed commit and branch, which stays in the fork until someone opens the pull request from it or deletes it.

What the agent does

The producer need not be an agent. Any caller job can upload outputs.jsonl and pass its artifact name as proposals-artifact to the reusable workflow; see the ten-line caller example. The same collector, policy check and apply handlers run on separate machines, without sandbox setup or inference. For non-patch requests only outputs.jsonl is needed. Optional base.json and aw-BRANCH.patch use the same hand-back contract and patch checks described below; no other files are accepted.

run's brief tells the agent to put its requests in out/safe-outputs.jsonl under its home and to leave its change uncommitted. run then makes the patch itself, with git running as the sandbox user: git format-patch of one commit against the commit the run started from, with gh-aw's X-GH-AW-Base-Commit header. The hand-back is outputs.jsonl, base.json and at most one aw-BRANCH.patch, and for a secondary pull request its own patch and secondary-base.json. gh-aw's agents call an MCP server that validates each request as it is made; here it is a plain file.

Where the policy has [memory], run also packs the flat directory ~/memory/ into memory.json, {"files": {"NAME": "text", ...}}, for an analysis run as for a branch run. Like replacement.json, it is agentic-job's file, not a gh-aw request type, and gh-aw's collector never reads it. Each file is read as the sandbox user, without following links. No directory hands back no memory.json, which leaves the memory as it was; an empty one hands back {"files": {}}. A link, a subdirectory, a secret-shaped string or any file check would refuse drops the whole memory, with a warning in the run's summary (memory_dropped); the change and the requests are handed back all the same. A hand-back of memory alone is uploaded like any other.

The requests file is read the same way, and one the agent left that is not taken (a link, not a regular file, over 1 MiB, or unreadable) drops the whole hand-back, with the reason in the run's summary (requests_dropped, and patch.error for the change). The run does not make up a pull request from the outcome in its place: that would hand the change back under words the agent did not write and lose every request it did, a noop or an open checkpoint among them. A session that reminds the agent of a missing hand-back names the file and the limit to it first.

The brief tells the agent to leave a part of a change that check would refuse out and say so, except when it is the whole task: then the agent makes it anyway, so that the run fails with check naming the file rather than ending well with nothing handed back.

The brief holds only what every run needs: where it hands back, its limits, and the validator. agentic-job handback guide prints the rest for the agent that needs it: the formats, the protected paths, the checkpoint, replacing a branch's commits and containers behind the proxy. agentic-job handback check tells the agent, while it can still fix it, what the run would refuse or lose of what it would hand back now. It runs run's own capture, on a copy of the checkout, since capture commits there; then check's own checks on the result; then the shape scan of agentic-job handback scan, which stands in for the final gate's scan by value of the run's own secrets (above). It also reports a missing or unreadable outcome.json, and a change left as a draft by its checkpoint. Each problem is a line, WHERE: WHAT; HOW TO FIX, and the command exits 1 while there is one. It reads the run's policy, checkout and base from ~/.config/agentic-job/handback.json, which run writes without the clone URLs. The agent can change that file, and so mislead only itself: the check job reads the policy the policy job printed. The requests are validated by collect, which fixtures hold to gh-aw's collector, so a missing or malformed field is refused in the session in the words the check job would use. handback guide lists each allowed type's fields from the same table.

For a push_to_pull_request_branch request without message, run fills that field from the outcome summary (or its default run description), the same fallback it uses for the patch's commit message. Where the outcome has no summary, the default description says the change is partial: an agent that wrote none may have been cut off before it could. An explicit message is preserved, including an invalid value for the collector to refuse. This does not validate requests while the producer runs or replace the separate check job.

The check, in two halves

The check job runs on a machine the agent never touched, with read access only. First the collector, agentic-job collect: which types exist, how many of each, the fields of each, and the sanitizing of their text. Then

agentic-job check --policy FILE --outputs DIR --collected FILE [--report FILE] [--run-id ID]

takes the collector's result and does none of it again, except to count. It refuses a hand-back holding any other file, a file over its size or a private key, and reports any other string shaped like a credential as a warning; a request naming another repository or base than the policy's; a pull request that asks not to be a draft, or does not come with exactly its patch; and, under ready_when_complete, a malformed checkpoint. It refuses any request holding agentic-job-applied, the name apply hides in what it posts so that a re-run leaves it out (running apply again).

It refuses a memory.json where the policy has no [memory], and otherwise holds it to those bounds, beside a patch or a replacement: no unknown field, no name given twice, no more files or bytes than the bounds allow, names that match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ without .. and end in one of the listed extensions as written (.MD is not md), and UTF-8 text with no control character but newline, carriage return and tab. A secret-shaped string is refused in the names and text as JSON decodes them, not only in the file as written. The report and the summary count its files and bytes and never show what they say.

The workflow also supplies --comment-repo OWNER/REPO --comment-target NUMBER when the caller fixes a comment destination. These paired options bind comments to that repository and issue/PR, independently of the code repository. Explicit redirections, including PR-number aliases and existing-comment/reply IDs, are refused rather than silently retargeted.

The patch is read as git reads it and must be exactly what format-patch writes. Refused are protected files (the policy's, and git's own files and CI, hook and editor configuration), paths that are not plain relative ones, binaries, symlinks, submodules, new executables, mode changes, renames, and a patch over its file count. Beyond what the old tree's check refused, it also refuses a change carried in the commit message, a second mail, any mail header format-patch does not write, and a file whose header states no mode: git would apply each, and a reader looking for diff --git lines would miss it. --report is the verdict as JSON, with the paths the patch names. CI runs a corpus of hand-backs through the old tree's check and this one, which must agree but for those differences (crates/agentic-job/tests/corpus). crates/agentic-job/tests/check.rs checks the hand-backs of two real runs (safe-outputs/fixtures).

check reads text, on a machine without the repository. It cannot see that git am --3way follows renames, that the base commit is only the hand-back's claim (#17), or whose the patch's From: line is, which it holds to nothing (#22). The first two are settled by the apply job and the third is not: what the apply job does before it writes.

Who applies

The rule, in the operator's words: "I like what GH AW does requiring a separate job VM that does the apply and only that VM has the write secrets."

The apply job has a machine of its own and a write credential. Event notification jobs can also write, but do not apply agent outputs. Apply runs gh-aw's pinned handlers on the checked outputs, and nothing else that came from the agent's machine. The workflow declares one secret, names it only in that job, and never uses secrets: inherit, which would hand every secret of the caller to the agent's job too. Which token, and how to set it up, is in the apply job and its token. If it is a classic personal access token of a bot account, everything that account can do is reachable from the apply job.

What gh-aw's scripts need

This is for a workflow gh-aw did not generate; the reusable workflow is the worked example. The setup action is given the destination ${{ runner.temp }}/gh-aw/actions. It empties /tmp/gh-aw, so a job puts its files there afterwards. Each script runs in an actions/github-script step that calls setupGlobals(core, github, context, exec, io, getOctokit) from setup_globals.cjs and then the script's main().

gh-aw's collector would read three paths from its environment: GH_AW_SAFE_OUTPUTS (the agent's outputs.jsonl), GH_AW_SAFE_OUTPUTS_CONFIG_PATH (.safe_outputs of policy.json, with a secondary pull request's max added to create_pull_request's) and GH_AW_VALIDATION_CONFIG_PATH (validation.json). It writes /tmp/gh-aw/agent_output.json, {items, errors}, and succeeds whatever it refused. check.yml runs collect in its place, which writes the same file and exits 1 where it refused a line; either way, the job fails on a non-empty errors itself.

The handlers read GH_AW_AGENT_OUTPUT, GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG (JSON, in the variable itself), GH_AW_WORKFLOW_ID and GH_AW_WORKFLOW_NAME. They need the patch at /tmp/gh-aw/aw-BRANCH.patch; for a patch, a checkout of the output repository at the base branch, whole and with credentials persisted, since they push with it; git's user.name and user.email (git am keeps the patch's author and needs a committer); and the token of their step for the API. Comment-only results need the API token, not a checkout or a fetched base. The configuration is .safe_outputs of policy.json with settings added: create_pull_request gets signed_commits: false (a signed push is made through the API as the token's owner), fallback_as_issue: false, preserve_branch_name: true, auto_close_issue: false (apply supplies the issue reference itself, rather than the handler), target-repo, base_branch, branch_prefix, title_prefix, and the applying account's fork (or the output repository, where its owner applies) as head-repo and as the one entry of allowed_repos, which the handler requires of a head-repo; the agent cannot name it, since check refuses a request's repo. Without a fork there is no create_pull_request handler (pull requests come from a fork). add_comment, hide_comment and the review comment types get target-repo and target. They list what they made in /tmp/gh-aw/safe-output-items.jsonl. Where the forge refuses a pull request, the upstream handler would open a fallback issue despite fallback_as_issue: false; the workflow lets no handler open more issues than the run's create_issue outputs allow.

Part of what the handlers log is the agent's text: the patch's first lines before git am, and git's own Applying: subject. The runner reads a workflow command in any line a step writes, so a commit message could set a step output no handler wrote (created_pr_url, which conclude reports as the run's result) or mask text from the log. The reusable workflow hands the handlers a core and an exec whose output is escaped as run escapes the agent's log (workflow/workflow-commands.cjs); a workflow of your own should do the same, since check cannot refuse ##[ in file content the preview prints too.

CI drives collect and the handlers with the scripted agent, and safe-outputs-probe.yml drives gh-aw's own collector and handlers on two fixtures when dispatched. Deployment evidence is in the README.

Off GitHub

policy, collect and check need no forge; gh-aw's handlers run under actions/github-script. A caller that is not on GitHub (Forgejo, later GitLab) runs

agentic-job collect --policy FILE --outputs DIR --collected FILE

on the checking machine, then check with the same --collected, as check.yml does on GitHub (above). It reads outputs.jsonl (at most 1 MiB, never through a link) and writes the {items, errors} gh-aw's collector would, given the policy's safe_outputs, with gh-aw's URL policy allowed-or-code-region and the policy's allowed hosts. It exits 1 if it refused a line.

It takes every type a policy may allow, the 24 of safe-outputs/validation.json: their fields validated and their text sanitized as gh-aw does (HTML comments, tags and link titles, URLs and their hosts, entities and hiding characters, template delimiters, unbalanced fences, the length limits), and the rules gh-aw checks a type against as a whole, such as an update_issue changing something or a review comment's start_line not after its line. A type outside that table is refused rather than passed on unsanitized. A test compares its result with what gh-aw's collector wrote for the same lines and configuration (crates/agentic-job/tests/data/collect, regenerated with just collect-fixtures GH_AW_JS when gh-aw's pin moves), and its built-in field rules with safe-outputs/validation.json. Where they differ it mostly refuses more (a line that is not JSON is not repaired); the other differences are in characters no renderer takes for a line end, listed in src/collect/mod.rs. Like gh-aw's, it passes a few fields on unchecked, where gh-aw has no check for their type (assign_to_user's assignees, update_project's fields, …); check holds those to the policy.

As gh-aw's collector does, it leaves @-mentions in what opens or comments on an issue, a pull request or a discussion to the applier, which knows who may be mentioned where it posts; in the other types, such as noop, missing_tool and missing_data, it wraps them in code. For Forgejo, the applier is apply, described below. On GitHub, gh-aw's handlers remain the applier.

Publishing to Forgejo

FORGEJO_TOKEN=... agentic-job apply --forge forgejo --server URL --repo OWNER/NAME \
--policy policy.json --checked DIR --run-url URL [--comment-target NUMBER] \
[--push-repo OWNER/NAME] [--recover-pushed-commit SHA]

publishes what check accepted to a Forgejo (or Gitea) repository, from a job of its own that holds the write token, as the apply job does on GitHub. DIR is what the check job uploads: check's report.json and the patch it accepted. The token comes only from FORGEJO_TOKEN, goes only in an Authorization header (to git through its environment), and is taken out of any error. Nothing names a host: --server and --run-url are the caller's. Remote servers require HTTPS unless the run's bounds name their authority in allow_http (empty by default); literal loopback addresses remain accepted. Like clone URLs, this is an exact, case-insensitive match including the port: forgejo:3000 does not admit forgejo:3001, forgejo.evil or xforgejo. Opting in sends the publish token and writes unencrypted over that network.

It publishes two outputs: add_comment, to --comment-target in the repository, and create_pull_request. Any other type is refused by name, as is a report check refused. Before anything is written, apply holds the report to the policy again: the repository, max_outputs and each type's count, a request naming another repository, item, base or draft state, the applied marker, the handlers' length and link limits, and the patch (its name, size, base commit and check's patch rules, read again). A same-sized patch changed after check is not caught: the upload has to come from the check job, as on GitHub, where apply holds it to its digest. The bodies are the collector's, sanitized once; gh-aw's handlers' second pass is not repeated. Before publication, apply inserts a zero-width break in user and team mentions and Forgejo !N references outside code spans and fences. Closing and reopening references share the collector's cap of ten; later ones are neutralized too. Then the pull request is opened, its push making the last checks, and the comments posted after it; a failure after a write names what was made.

A pull request's branch goes to a fork, never to the target repository. --push-repo is required for one and must be the push_repo the bounds name (above), never one a hand-back names. It must be another owner's repository, since Forgejo reads a head of the target's own owner as the target's own branch. policy cannot tell a Forgejo target from another, so bounds without push_repo admit a pull request that apply then refuses. Before pushing, apply reads both repositories through the API. It refuses a push repository that is not a fork of the target, and, unless it is private, one whose target is not public: private, internal, under an owner that is not public, or of a private run. It also refuses when the forge does not report one of these.

For the pull request, apply fetches the base branch from the target into an empty repository, applies the patch on check's base commit with git configured only by apply (no hooks, no credential helpers, no redirects; carriage returns kept, as on GitHub), refuses it if it changed any path the patch did not name, and pushes it to the request's branch in the fork, which must not exist. Then POST /api/v1/repos/OWNER/NAME/pulls opens it against the target with head FORK_OWNER:BRANCH. Forgejo has no draft pull request, so a draft's title starts with WIP:, which it refuses to merge; under ready_when_complete, complete work opens ready.

If the push succeeds but opening the pull request fails, the error names the fork, branch and full commit SHA. Retry the same checked input with --recover-pushed-commit SHA to open the PR without another push. Recovery requires both the reconstructed checked commit and the remote branch to match that exact SHA; it refuses changed inputs or a moved branch. The committer date is the patch's author date so retries reconstruct the same commit. If the POST's response was lost, first check whether the PR already exists; recovery does not detect an already-created PR.

The pushed commit's message is the patch's, verbatim, as git am reads it; apply adds nothing to it. A pull request's or comment's body is the agent's body, then a blank line and the footer > Generated by [agentic-job](RUN), where RUN is --run-url:

BODY
> Generated by [agentic-job](RUN)

If the body ends with a trailer block, the footer goes before it instead, and the block stays the body's last lines byte for byte:

BODY
> Generated by [agentic-job](RUN)
Generated-by: https://github.com/cgwalters/#llms
Closes #5

A trailer block is the run of Key: value lines and Closes #N, Fixes #N or Resolves #N lines (in any case) that ends the body, with no blank line among them, after a blank line or at the body's start. BODY is what comes before it, without trailing whitespace. A trailer-shaped line elsewhere stays where it is. apply writes no agentic-job-applied marker on Forgejo, so nothing else is added.

Because the pull request comes from a fork, the target's CI treats it as a fork's pull request. check's protected files keep the patch out of CI configuration, but not out of the code a workflow runs, so the agent's code runs in the target's pull_request workflows. The operator must make sure those workflows give a fork's pull request no secrets and no write token. That is Forgejo Actions' default: a pull_request run from a fork gets no repository or organization secrets, and its automatic token can only read. To check, read the target's workflow directories (.forgejo/workflows/, or .github/workflows/ without it). No pull_request_target workflow may check out or run the pull request's head, since it runs with the target's secrets. No workflow may hand a credential to jobs through anything but secrets (a runner's own environment or files, for example). Runners that take these jobs must hold no credential of their own. The fork's own push workflows run the code too, so the fork should have Actions disabled or no secrets. Comments involve no push.

Not done: re-run detection (a re-run with a pull request is refused at its branch; one with only comments posts them again), reviews, labels and pushes to an open pull request.

Neutral profile

The requests stay gh-aw's JSONL on every forge, with its type and field names: agents already know them, and nothing on the line changes. What differs per forge is which types and fields mean the same thing there. That is the neutral profile, from the research and plan in #1393. The bounds' forge selects it, and policy.json carries it as forge and contract. The contract is a number, now 1, that follows the gh-aw commit pinned above and is raised when a type or field changes meaning there. check refuses a policy written for another contract. A policy without the two keys is GitHub's, at the current contract.

gh-aw typeGitHubForgejoGitLab
add_commentissue commentissue commentnote on an issue or a merge request
create_pull_requestpull request from a fork; draftpull request; draft is a WIP: titlemerge request from the fork's project; draft is a Draft: title
push_to_pull_request_branchgit pushgit pushgit push
create_pull_request_review_commentreview commentreview comment, by line numberdiscussion with a base, start and head commit position
add_labels, remove_labelsby nameby name or IDby name; apply must refuse a label that does not exist, which GitLab would create
close_issue, close_pull_requeststatestatestate_event
create_issueissueissue (labels by ID)issue
noop, missing_tool, missing_datano writeno writeno write

These are the types a bounds file off GitHub may list; policy refuses any other there, and check refuses a policy that holds one. The list is of what is held, not of what is refused, so a type nobody has mapped stays GitHub-only. GitHub-only because no other forge has the object: create_discussion, update_project, hide_comment, link_sub_issue and create_check_run (check runs are for GitHub Apps; a neutral status with a pinned context would be the common subset). GitHub-only until someone maps them: update_issue, update_pull_request, mark_pull_request_as_ready_for_review, add_reviewer, assign_to_user, reply_to_pull_request_review_comment and resolve_pull_request_review_thread. A forge-native operation added later gets a forge prefix, as gh-aw does for its Jira and Linear types.

A request names the kind of item it targets. GitHub and Forgejo number issues and pull requests together, so gh-aw's item_number is one item there. GitLab's #5 and !5 are different items, so check refuses an item_number on GitLab. A type whose target must name its kind there uses gh-aw's issue_number or pull_request_number where it has them. add_comment, add_labels and remove_labels have only item_number in gh-aw's rules, and the collector drops a field its rules do not list, so on GitLab a comment goes only to the run's own item, and labels cannot be applied, until a GitLab collector admits a kind-named target for them.

The bounds that already exist are neutral: hosts, repos, bases, max_outputs, each type's max, push branches, label lists, protected files and dot-folders, and commit_author. A check run's name, a discussion's category and projects are GitHub's.

Apply acts as one identity per role, which the caller's configuration maps to a credential; a request never chooses one. Only apply holds it:

  • GitHub: SAFE_OUTPUTS_PAT (or the job token), later a role app token (tracker#766).
  • Forgejo: a bot user's token.
  • GitLab: a project or group access token, a bot member with a role.

Pushing and opening a change can be separate roles, as in service-gator: a fork identity that may push, and an upstream one that may only open pull requests. On Forgejo and GitLab those can be two tokens.

The Forgejo publisher above implements only its listed subset of this contract. A GitLab publisher is still to be written; before it writes, it needs the mention filter above and must neutralize quick actions, a /close or /merge line that GitLab runs with the poster's rights.