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 = 3projects = ["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 = 3allowed = ["triage", "documentation"]blocked = ["release"]
[outputs.remove_labels]max = 2allowed = ["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 = 1fields = ["body"] # of "title", "body", "status"
[outputs.update_pull_request]max = 1fields = ["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 = 1allowed = ["octocat"]
[outputs.assign_to_user]max = 1allowed = ["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 = 1name = "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 = 1category = "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 = 3allowed_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 = 1branches = ["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.githubexemption 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
.githubexemption 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
headand pushes one commit without force, with no fallback to a new pull request. Only the.githubexemption enablesallow_workflowsand the matching dot-folder exclusion. A branch that moved sinceheadwas 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 onheadwith 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 onheadwith 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 100max_file_bytes = 16384 # at most 102400 (100 KiB)max_bytes = 65536 # all files together; at least max_file_bytes, at most 1 MiBextensions = ["md", "txt", "json"] # from md, txt, json, jsonl, csvWith 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 belowA 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 = 1max_patch_bytes = 65536max_patch_files = 10It 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 FILEon 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/#llmsCloses #5A 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 type | GitHub | Forgejo | GitLab |
|---|---|---|---|
add_comment | issue comment | issue comment | note on an issue or a merge request |
create_pull_request | pull request from a fork; draft | pull request; draft is a WIP: title | merge request from the fork's project; draft is a Draft: title |
push_to_pull_request_branch | git push | git push | git push |
create_pull_request_review_comment | review comment | review comment, by line number | discussion with a base, start and head commit position |
add_labels, remove_labels | by name | by name or ID | by name; apply must refuse a label that does not exist, which GitLab would create |
close_issue, close_pull_request | state | state | state_event |
create_issue | issue | issue (labels by ID) | issue |
noop, missing_tool, missing_data | no write | no write | no 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.