Security questionnaires have started carrying an AI section. Somewhere in it is a version of this question: does AI have write access to your production code, and who reviews what it changes.
You answer that in writing, and it gets attached to the contract. There is usually no list to answer it from, so the answer gets written from memory, and it becomes a representation nobody can check six months later when someone asks whether it is still true.
The fix is a list. Here is what goes on it, and what to run to pull it.
What counts
A non-human identity is anything that can act without a person present. Bot accounts, service accounts, API tokens, deploy keys, installed apps, workload identities, and agents.
Scope the first pass to write access. Read access matters, and it is a much longer list, and starting there is how the exercise dies at lunchtime. Anything that can change code, change what gets deployed, or change what runs in production is in scope. Everything else goes in a second pass.
Where to look, and what to run
The commands below are GitHub and AWS because that is the common case. GCP and Azure get their own note further down, because the sharp edge is in a different place on each.
Deploy keys. Per repository, and the write-enabled ones are the point. Needs repository admin:
gh api /repos/OWNER/REPO/keys --paginate \
--jq '.[] | select(.read_only == false) | .title'
Installed apps. Organization level, each carrying a permission set somebody accepted once during setup. You have to be an organization owner. GitHub’s own page for this endpoint asks for admin:read, which is not on GitHub’s own list of valid scopes and cannot be selected when you create a token, so expect to find the working one by trial rather than from the documentation:
gh api /orgs/ORG/installations --paginate \
--jq '.installations[] | [.app_slug, (.permissions | tostring)] | @tsv'
The response is an object wrapping the array, not an array, which is why the filter reaches into .installations[] rather than iterating the top level.
The branch protection bypass list. The shortest list in your organization and the one worth reading first, because everything on it can skip the review you are about to promise a customer:
gh api /repos/OWNER/REPO/branches/main/protection \
--jq '{restrictions, bypass: .required_pull_request_reviews.bypass_pull_request_allowances}'
That endpoint reads classic branch protection and nothing else, which is the trap in this section. It returns 404 rather than an empty object when classic protection is not configured, and 404 does not mean unprotected: a repository whose rules come from a ruleset is protected and returns 404 here anyway. Read it as “no protection” and the inventory reports a correctly protected repository as an open door, which is the one error that discredits the whole list.
The rules actually in effect on a branch, from both mechanisms, are on a different endpoint:
gh api /repos/OWNER/REPO/rules/branches/main --jq '[.[].type] | unique'
An empty array there is a real answer: nothing applies to that branch. But that response is evaluated for you, so it leaves out the part you came for. Bypass actors belong to the ruleset rather than to the branch, so read the rulesets:
gh api /repos/OWNER/REPO/rulesets --jq \
'.[] | [.id, .name, .enforcement, ([.bypass_actors[]?.actor_type] | join(","))] | @tsv'
Three things in that one line. enforcement is active, disabled, or evaluate, and the last two block nothing, so a repository can carry a complete set of rules and enforce none of them while the rules page looks identical. bypass_actors is returned only to a caller with write access to the ruleset, which means an empty bypass column can mean nobody can bypass or it can mean you could not see, and those are opposite answers to the question you are asking. And the endpoint includes rulesets inherited from the organization by default, which is what you want here: an organization ruleset is enforced on the repository whether or not anybody at the repository knows about it.
Smaller traps in the classic response: restrictions is null on user-owned repositories, and bypass_pull_request_allowances exists only when required reviews are configured, so check the parent before reaching through it.
Personal access tokens, where the platform will not give you a full list. This is the gap most inventories paper over, and being exact about it is what a reviewer will test.
No REST endpoint enumerates classic personal access tokens across an organization. GET /orgs/{org}/personal-access-tokens covers fine-grained tokens only, is callable only by a GitHub App rather than by your own token, and lists only what your approval policy has surfaced. If the organization does not require approval for fine-grained tokens, member tokens reach organization resources without ever appearing there, so the list is not the inventory it looks like.
The one partial exception is a SAML SSO organization, where the authorizations do include members’ classic tokens:
gh api /orgs/ORG/credential-authorizations --paginate \
--jq '.[] | [.login, .credential_type, .token_last_eight,
(.authorized_credential_expires_at // "never")] | @tsv'
The expiry field is authorized_credential_expires_at, not credential_expires_at, and getting it wrong fails quietly: the wrong name resolves to null, the fallback prints “never”, and every row reports a non-expiring credential. A page of findings that are not real. The command returns the last eight characters of a token and never the token, which is enough to match against what a developer tells you and nothing else.
Assume you are in the other case. SAML SSO is a GitHub Enterprise Cloud feature, and a company without a security team is usually on Team, so for most readers that command returns nothing and the fallback is the real path: read the audit log for token-authenticated events, then ask people what they are holding. Record that as a stated gap in the inventory with the reason, rather than leaving the column blank. A blank column reads as “we did not check.”
CI permissions. Grep the workflows rather than clicking through them:
grep -rn "id-token: write\|contents: write\|packages: write" .github/workflows/
id-token: write is the interesting one. That is the token your CI exchanges for cloud credentials, which makes it the bridge between the two halves of this list.
Cloud trust policies. Find the roles GitHub can assume, then read how tightly each one is scoped:
aws iam list-roles \
--query "Roles[?contains(to_string(AssumeRolePolicyDocument), 'token.actions.githubusercontent.com')].RoleName"
AssumeRolePolicyDocument is the trust policy, where a federated principal lives. The raw IAM API returns it URL-encoded, but the CLI decodes it to an object before --query sees it, which is why to_string is there: contains needs a string.
For each role that comes back, read the condition on token.actions.githubusercontent.com:sub. The finding is usually here, and it turns on one operator:
StringEqualswithrepo:ORG/REPO:ref:refs/heads/mainis scoped to one branch.StringLikewithrepo:ORG/REPO:*is scoped to the whole repository.
Be exact about what it costs, because the overstated version is easy to write and someone will check it. It matches every branch, and the pull request subject repo:ORG/REPO:pull_request that a branch-scoped policy excludes. It does not hand a token to an outside contributor: GitHub withholds OIDC tokens from fork-triggered pull_request runs entirely, even when the workflow asks for id-token: write. The widening is to every internal branch and pull request, raised by people who already have write access. Worth closing, and not exposure to strangers.
Repositories created after 15 July 2026 use an immutable subject carrying numeric ids, repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:refs/heads/main, so a policy written against the older form will not match and the failure looks like a permissions problem. Renames and transfers after that date adopt the new form too, which is the nastier case: a repository that was working stops, and nothing about the rename suggests it touched your cloud trust policy. Environment names in the subject are case sensitive, so environment:Production and environment:production are different trust relationships.
That environment form is the one to aim at: scope the policy to repo:ORG/REPO:environment:Production and put protection rules on the environment, so a token is minted only for a run that has cleared a required reviewer and a branch restriction. About the same configuration as branch scoping and a good deal stronger, because it gates the deployment rather than the merge.
The same question on GCP. List the pools, then the providers in each, and read the attributeCondition on the provider. Both commands need --location and error without it:
gcloud iam workload-identity-pools list --location="global"
gcloud iam workload-identity-pools providers list \
--location="global" --workload-identity-pool="POOL_ID"
This is the sharpest version of the finding on any of the three platforms, and Google documents it rather than leaving you to work it out. GitHub uses one issuer for every repository on github.com, so a provider whose attribute condition does not pin assertion.repository_owner to your organization can be impersonated from repositories you do not control. A different category from the AWS wildcard: not too wide inside your organization, but outside it.
Google now refuses to create a provider with no condition, so what you are looking for is a condition that exists and pins the wrong claim, usually the repository or the branch without the owner.
The same question on Azure. The risk inverts here. Federated identity credentials match the subject exactly and wildcards are not supported, so there is no repo:ORG/REPO:* to find:
az ad app federated-credential list --id APP_ID
az identity federated-credential list --identity-name NAME --resource-group RG
What you find instead is sprawl. One credential per repository, branch and environment combination, added whenever somebody needed one and removed never, including credentials pointing at branches that no longer exist. Wildcards enter only through the preview claims matching expressions, where claims['sub'] matches 'repo:ORG/*' does what you would fear, so check whether anyone uses them.
Exact matching is why the July 2026 subject change bites hardest here: a credential written against the older form does not degrade, it stops matching for any repository created after that date.
Four more places belong on the list. No commands for these, because the tooling varies too much to be worth guessing at:
- Registries. Publish tokens for npm, PyPI or a container registry. A token that can publish a package your own systems install is write access to production by a longer route, and it is rarely owned by whoever thinks they own releases.
- Infrastructure state. Terraform Cloud tokens, or whatever holds your state file. Write access to state is write access to infrastructure, one apply later.
- Chat. Bots that can trigger a deploy or approve a release. ChatOps moves the authorization boundary into a workspace managed by a different team on a different offboarding checklist.
- Agents. Coding agents, review agents, anything behind an MCP server. Each holds a credential belonging to somebody, and an agent running under a developer’s token has that developer’s access, including whatever they are allowed to approve.
What to record
Seven columns. Fewer and the inventory does not answer the questionnaire. More and you will not finish.
| Column | What goes in it |
|---|---|
| Identity | The name as it appears in the system that issued it |
| Type | Token, deploy key, app installation, workload identity, agent |
| Writes to | Named repositories, environments, registries. “Production” is not an answer |
| Auth | Static secret, short-lived token, federated identity |
| Expires | A date, or the word never. Never is the finding |
| Owner | A named person, not a team alias. Teams do not rotate credentials, people do |
| Approval | What it cannot do without a human, and which setting enforces that |
The last column is what the questionnaire is actually asking about. It is also the one that cannot be filled in from an API, because “enforced by branch protection” and “enforced by a sentence in a policy” look identical until someone tests it.
Those seven columns as a file: non-human-identity-inventory.csv. It has one row of instructions in it, which you delete.
What you will probably find
These come up often enough to expect them.
A credential issued by someone who has left. Removing them from the organization does cut it off, but revoking their SSO session does not, and the two are separate steps on separate checklists. The window is however long it takes the second one to happen, and the code host’s token page is rarely on it at all.
A trust relationship scoped wider than anyone intended. Usually a cloud role that any branch of a repository can assume, set up during a migration when the narrower version was not working and somebody widened it to unblock a release.
An agent running under a person’s identity. This is the one that matters most for the AI section of the questionnaire, because the honest answer is that the agent’s access is a human’s access, and the reviewer will read that as an unbounded answer.
Turning it into the answer
Once the list exists, the questionnaire question has a three-part answer: what non-human identities can write to production code, what each is scoped to, and what they cannot do without a person.
The third part is where most programs are thin, and two changes carry most of it.
Put every identity that can merge to a protected branch behind a required review, and then check the bypass list, because those are two different settings and the second one is what actually decides. A rule with an exception list nobody has read is not a control.
Then give every agent its own identity instead of borrowing a person’s. The options are not equivalent:
- A machine user is the fastest and the worst. It consumes a seat, it has a password and a recovery email, and its credentials are long-lived.
- A GitHub App issues installation tokens that expire in an hour and are scoped per repository. It cannot sit in a team, which is the point.
- A fine-grained PAT expires and is scoped, but it belongs to a person, so it inherits their offboarding.
For an agent you run yourself, the app is the answer and the migration is an afternoon. For a vendor agent you do not control the auth flow, and plenty of them accept nothing but a PAT. Give that one a dedicated fine-grained token on an account of its own, and put the account on the inventory with a named owner, which is the honest version rather than the clean one.
What makes the review real
A required review answers half the question. It says a second identity approved the change. It does not say a person did.
GitHub blocks the obvious case: pull request authors cannot approve their own pull requests. The case it does not block is two agents. Give your coding agent its own app, as above, then give a review bot another one, and the second can approve the first. Both are non-human identities on the list you just built, and the merge looks exactly like a reviewed merge from the outside.
The one review GitHub explicitly calls out as not counting toward a requirement is Copilot’s. If one automation’s approvals had to be called out, the safe assumption is that everything else counts until you prove otherwise on your own repository.
One piece of it you can read directly:
gh api /repos/OWNER/REPO/actions/permissions/workflow \
--jq '{perms: .default_workflow_permissions, approve: .can_approve_pull_request_reviews}'
can_approve_pull_request_reviews is off by default for new repositories and new organizations, and GitHub’s own reference says enabling it is a security risk. Check the organization too, because that is where a repository inherits from:
gh api /orgs/ORG/actions/permissions/workflow --jq '.can_approve_pull_request_reviews'
That setting covers workflows. It does not cover an app or a machine user, and nothing in the branch protection response tells a human approval apart from an app’s. So if the answer you are about to write says a person reviews what the agent changes, the setting that gets closest is require review from code owners. It gets you part of the way, and the two gaps are worth knowing before you write that sentence down.
A CODEOWNERS file takes usernames and team names, and GitHub requires them to have explicit write access to the repository. There is no syntax in it for an app, which is the useful half: an app cannot be a code owner, so an app’s approval cannot satisfy the requirement. A machine user can. It is an ordinary account with write access, so it is a valid code owner and its approval counts like anybody else’s. The setting excludes apps, not automation. If you run machine users, keeping them out of CODEOWNERS is the control, and the setting alone is not.
The second gap is coverage. A code owner review is required only for paths that match an entry, so a pull request touching only unmatched paths merges with no code owner approval at all. Start the file with a catch-all and narrow from there:
* @your-org/engineering
Without that line the rule applies to whatever you remembered to list, and the answer you send describes a control with holes in it you cannot see from the settings page.
Also require an approval from someone other than the last person to push, which stops the agent that pushed a fixup commit from being the approval on its own change. And dismiss stale approvals when a push changes the diff, so the approval covers the code that merges rather than the code somebody looked at an hour earlier.
The path the list cannot see
There is one way AI reaches your code that no inventory will show you. A developer using an assistant in their editor commits the result under their own account. There is no separate identity to enumerate and nothing on the list represents it, so if your developers work that way, the list is silent about the path most of your AI-written code takes.
That is not a hole in the control. The same required review covers it, because the code shows up as a person’s pull request and gets reviewed as one. It is a hole in what the list proves, and the difference matters when you write the answer. An inventory of identities that can act on their own answers the first half of the question. The editor path is answered by the review requirement, not by the inventory, and it is answered for every change rather than for the ones you enumerated.
Say both halves. A reviewer who asks the follow-up and gets the inventory back has caught you claiming more than you checked.
What you actually send
The three-part answer, written out. Replace the bracketed parts from your inventory, and delete the lines that do not apply to you:
AI-assisted changes reach production only through pull requests, whether an agent opens them or a developer writes them with an assistant in their editor. Assistant-written code is committed under the developer’s own account and is reviewed under the same requirement as any other change. [N] non-human identities can act on their own against protected branches: [names, from the Identity column]. Each is scoped to [repositories and environments, from Writes to], authenticates by [from the Auth column], and holds credentials that [expire on this date, or rotate on this schedule, from Expires]. CODEOWNERS carries a catch-all entry, so every path requires an approving review from a named person; the only exceptions are the bypass actors listed here: [names, or “none”], last reviewed [date]. GitHub Actions is not permitted to approve pull requests in this organization. The inventory is diffed against live configuration weekly, and drift opens a tracked issue. The branch protection configuration is attached, exported [date].
Then attach the configuration rather than describing it:
gh api /repos/OWNER/REPO/branches/main/protection > protection-$(date +%F).json
gh api /repos/OWNER/REPO/rules/branches/main > rules-$(date +%F).json
Both, for the reason above. The first returns 404 on a repository protected by rulesets, so on its own it attaches either an error or nothing, on the exact control the answer rests on. That file is the difference between a claim and a control. It is also the thing that dates well: export it again when they ask next year, and the diff between the two is your answer to whether it is still true.
When this is the wrong work
If the questionnaire is due this week, export what you have and answer from what is true today. Changing branch protection the day before you send an answer means the answer describes a configuration nobody has lived with, and the next question is always how long it has been that way.
If you do not merge through pull requests, none of this applies and the honest answer says so. A questionnaire that assumes a review gate is asking about a workflow you do not run, and inventing one on paper is worse than explaining the one you have.
If you already run a compliance platform, look at what it holds before you build anything. Those tools are organized around people, roles, and access reviews, and depending on what you have connected to yours, some of these identities are already collected with evidence attached. What they tend not to produce on their own is the write-scoped view this list is for: which non-human identity can change production code, and what it is scoped to. Pull what the platform already knows and build the missing columns onto it. Two inventories that disagree is a worse position than one, and the reviewer will find the seam.
Keeping it true
An inventory is accurate on the day you build it. What makes it a control rather than a document is whether anything notices when it stops being accurate.
The cheap version is a scheduled job running the same commands above, diffing the result against the inventory committed in your repository, and opening an issue when they disagree:
name: identity-drift
on:
schedule: [{ cron: "17 13 * * 1" }]
workflow_dispatch:
permissions:
issues: write
jobs:
diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: bash scripts/pull-identities.sh > current.tsv
env:
GH_TOKEN: ${{ secrets.INVENTORY_READ_TOKEN }}
- id: drift
run: |
if diff -u inventory.tsv current.tsv > drift.diff; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
code=$?
if [ "$code" -gt 1 ]; then echo "diff failed with $code" >&2; exit "$code"; fi
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.drift.outputs.changed == 'true'
run: gh issue create --title "Identity drift" --body-file drift.diff
env:
GH_TOKEN: ${{ github.token }}
Most of that is there because the obvious version does not work, and the ways it fails are worth more than the file.
diff exits 0 when the files match and 1 when they do not, and a step that exits non-zero fails the job, so the one-line version turns every drift into a red cross with no issue and no readable diff. Worse, diff exits 2 when a file is missing, which is what happens when the pull script fails, so the case where you learned nothing looks exactly like the case where something changed. Branching on the code separates the three, and code=$? has to be the first line of the else or you are reading the exit status of something else.
The two tokens are different on purpose. The pull needs read access across the organization; opening an issue needs issues: write, which a read-scoped inventory token should not have. If you wire both to the same secret you will either fail to open the issue or hold a token with more rights than the job needs.
The rest is scheduling. A scheduled workflow only runs from the default branch, so a drift checker sitting on a branch never fires once. On a public repository the schedule is disabled automatically after sixty days without activity, which is worth recognizing rather than planning around, because an inventory repository should be private and the rule does not apply there. workflow_dispatch is there so you can prove it works today instead of finding out at 13:00 on a Monday. And the minute is 17 rather than 0 because the top of the hour is when everyone else schedules and GitHub delays under load.
There is a catch worth naming, because it is the kind of thing that makes a reviewer trust the rest of your answers. That job needs a credential that can read your organization’s settings. That credential is itself a non-human identity with standing access, so it goes on the inventory, it gets the same seven columns, and it is the one place where the list has to describe itself.
Which is the honest state of this problem. You do not get to a number that stays at zero. You get to a list that tells you when it is wrong.