Skip to content

Read-only mode for gh via GH_READ_ONLY env var#13621

Open
jennybc wants to merge 2 commits into
cli:trunkfrom
jennybc:jennybc/read-only-mode
Open

Read-only mode for gh via GH_READ_ONLY env var#13621
jennybc wants to merge 2 commits into
cli:trunkfrom
jennybc:jennybc/read-only-mode

Conversation

@jennybc

@jennybc jennybc commented Jun 8, 2026

Copy link
Copy Markdown

Read-only mode for gh via GH_READ_ONLY env var

Context: I did this during a recent internal hackathon among the open source folks at Posit, where I work. I've read the contributing guidelines and realize this issue is neither marked as help wanted, nor does it have acceptance criteria yet! So I know this PR is unlikely to be merged. I share it as a working implementation of a safety feature related to discussion in #12522 and #12624. This was done with the help of Claude (I've never written Go), with strong human oversight.

What it does

Adds an opt-in read-only mode, enabled by setting the GH_READ_ONLY environment variable to something truthy. When set, gh can read and inspect GitHub resources, but cannot mutate anything on the server. This affects work via the REST and GraphQL APIs and any git push that gh performs (such as in gh pr create). Blocked operations fail before anything reaches GitHub, with a non-zero exit status. This would allow a conservative user to give broad permission for gh *, including the gh api raw passthrough, knowing that nothing will be modified on the server.

In terms of HTTP calls, you can't just block everything other than GET, since GitHub's GraphQL API uses POST for every operation. Therefore the guard inspects the request body to distinguish a query (allowed) from a mutation (blocked).

Test drive

gh-with-readonly is my local gh build from this branch.
By default, it works just like gh:

$ gh-with-readonly --version                                               
gh version 2.93.0-55-g44ed22017 (2026-06-08)
https://github.com/cli/cli/releases/latest

$ gh-with-readonly api user --jq .login
jennybc

$ gh-with-readonly pr list -R cli/cli -L 2

Showing 2 of 47 open pull requests in cli/cli

ID      TITLE                                                         BRANCH                                                 CREATED AT       
#13620  feat(discussion): add comment command                         babakks/add-discussion-comment                         about 5 hours ago
#13619  chore(deps): bump github/codeql-action from 4.36.1 to 4.36.2  dependabot/github_actions/github/codeql-action-4.36.2  about 8 hours ago

With GH_READ_ONLY set, you can still read via GraphQL. However, you can't change things on the server, e.g. you can't star a repo.

$ export GH_READ_ONLY=true  

$ gh-with-readonly pr list -R cli/cli -L 2

Showing 2 of 47 open pull requests in cli/cli

ID      TITLE                                   BRANCH                                  CREATED AT       
#13620  feat(discussion): add comment command   babakks/add-discussion-comment          about 6 hours ago
#13619  chore(deps): bump github/codeql-act...  dependabot/github_actions/github/co...  about 9 hours ago

$ gh-with-readonly api --method PUT "user/starred/jennybc/gh-readonly-demo"
Put "https://api.github.com/user/starred/jennybc/gh-readonly-demo": gh is in read-only mode (GH_READ_ONLY): this operation would modify data and was blocked
$ echo "exit=$?"
exit=1

Temporarily unset GH_READ_ONLY, so we can star a repo:

$ GH_READ_ONLY= gh-with-readonly api --method PUT "user/starred/jennybc/gh-readonly-demo"

Confirm the star exists and try (and fail) to delete it:

$ gh-with-readonly api "user/starred/jennybc/gh-readonly-demo" -i | head -1
HTTP/2.0 204 No Content

$ gh-with-readonly api --method DELETE "user/starred/jennybc/gh-readonly-demo"
Delete "https://api.github.com/user/starred/jennybc/gh-readonly-demo": gh is in read-only mode (GH_READ_ONLY): this operation would modify data and was blocked

gh shim to intercept agents

How to make sure agents use gh in read-only mode?

Proof of concept: I place a gh shim that looks for the env vars set by agents (e.g. AGENT, CLAUDECODE, GEMINI_CLI, CURSOR_AGENT) and executes such calls with GH_READ_ONLY set. This is not part of the PR, it's just so I can exercise the feature with local agents.

agent_env_vars=(AGENT CLAUDECODE GEMINI_CLI CURSOR_AGENT)
for var in "${agent_env_vars[@]}"; do
  if [ -n "${!var}" ]; then
    GH_READ_ONLY=1 exec "$HOME/.local/bin/gh-with-readonly" "$@"
  fi
done

Otherwise, the shim falls back to stock gh (not shown).

When I direct Claude Code to do things like create an issue or label, it is unable to do so:

⏺ Bash(gh issue create -R jennybc/gh-readonly-demo -t "Shim test" -b "testing" ; echo "exit=$?")
Post "https://api.github.com/graphql": gh is in read-only mode (GH_READ_ONLY): this operation would modify data and was blocked
exit=1

⏺ Bash(gh label create shim-test -R jennybc/gh-readonly-demo -c FF0000 ; echo "exit=$?")
Post "https://api.github.com/repos/jennybc/gh-readonly-demo/labels": gh is in read-only mode (GH_READ_ONLY): this operation would modify data and was blocked
exit=1

Notes

  • An environment variable felt better than a flag, in the sense that it doesn't absolutely rely on a (cooperative) agent following instructions.
  • Enforcement at client constructors (api.NewHTTPClient and git.Client.AuthenticatedCommand) seems like the most efficient way to get read-only behavior for all commands, given that the gh surface doesn't make it easy to distinguish reads from writes.
  • This is meant for a cooperative agent and doesn't remove the need for a sandbox or a read-only token, depending on context.
  • Only prevents remote mutations via gh. Local writes (e.g. gh config set) are unaffected, as is a git push outside of gh.
  • Covered by unit tests in api, git, and internal/ghenv: REST writes blocked, GET/HEAD allowed, GraphQL query allowed / mutation blocked, push blocked, off by default.

Copilot AI review requested due to automatic review settings June 8, 2026 23:38
@jennybc jennybc requested a review from a team as a code owner June 8, 2026 23:38
@jennybc jennybc requested a review from babakks June 8, 2026 23:38
@github-actions github-actions Bot added external pull request originating outside of the CLI core team needs-triage needs to be reviewed unmet-requirements and removed needs-triage needs to be reviewed labels Jun 8, 2026
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown

Thanks for your pull request! This is a large change (476 lines across 8 files) that doesn't reference a help wanted issue.

Large feature PRs require prior discussion in an issue before implementation — this helps the team assess whether the feature aligns with the project's direction before significant effort is invested.

Please open an issue to discuss this feature first. This PR will be automatically closed in 2 days if requirements are not met.

Full contribution requirements
  1. Include a detailed description of what this PR does
  2. Link to an issue with the help wanted label (use Fixes #123 or Closes #123)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds a GH_READ_ONLY environment flag that enforces a read-only mode across gh by blocking write operations (REST non-GET/HEAD, GraphQL mutations, and git push) while updating CLI help text and adding tests.

Changes:

  • Document GH_READ_ONLY in root help topics and gh api help.
  • Introduce internal/ghenv.ReadOnly() and enforce read-only mode in the API HTTP client via transport middleware.
  • Block git push when read-only mode is enabled, and add coverage for git and API behavior.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pkg/cmd/root/help_topic.go Documents the new GH_READ_ONLY env var in global help topics.
pkg/cmd/api/api.go Documents GH_READ_ONLY for the gh api command help text.
internal/ghenv/ghenv.go Adds ReadOnly() helper for interpreting GH_READ_ONLY.
internal/ghenv/ghenv_test.go Adds tests for ReadOnly() parsing behavior.
api/http_client.go Wraps transports with read-only middleware and implements GraphQL mutation detection.
api/read_only_test.go Adds tests ensuring read-only middleware/client blocks writes and preserves request bodies.
git/client.go Blocks git push via AuthenticatedCommand when read-only mode is enabled.
git/client_test.go Adds tests verifying read-only behavior for push/fetch paths.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

{value: "no", want: false},
}
for _, tt := range tests {
t.Run(tt.value, func(t *testing.T) {
Comment thread internal/ghenv/ghenv.go
if !ok {
return false
}
return !slices.Contains([]string{"false", "0", "no", ""}, value)
Comment thread api/http_client.go
Comment on lines +198 to +201
switch strings.ToUpper(req.Method) {
case http.MethodGet, http.MethodHead:
return nil
}
Comment thread api/http_client.go
Comment on lines +228 to +229
defer req.Body.Close()
body, err := io.ReadAll(req.Body)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

external pull request originating outside of the CLI core team unmet-requirements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants