
I tried having NemoHermes read a private repository without passing a GitHub token
This page has been translated by machine translation. View original
Introduction
Hello, I'm Morishige from Classmethod's Manufacturing Business Technology Department.
I want to have an AI agent read GitHub private repositories. But directly passing the GitHub token feels a bit concerning...
When I think back to my usual setup, writing GITHUB_TOKEN=ghp_... in .env and having the agent read it is the most straightforward approach. On the agent harness side, tools like Claude Code and Cursor have been gradually building in mechanisms for automatic .gitignore additions, secret detection, and restricting how env variables are passed, making accidents like "accidental commits" and "accidental log output" less likely than before.
However, the raw token still sits on disk on the host. The agent process can read its contents directly, and there are risks like passing it straight to curl in a tool call, or accidentally displaying it on screen during reasoning—risks that structurally can't be reduced to zero. Private repository tokens often carry strong permissions including PR merges and Workflow triggers, so having them sitting on disk where the agent can freely access them still feels a little unsettling.
NemoHermes / NemoClaw provides a mechanism called OpenShell Providers v2 as an answer to this concern. The actual GitHub token value is held by OpenShell, and only a placeholder—a marker string that stands in for the actual value—is shown to the agent.
This article walks through the steps to read a PR from a GitHub private repository through NemoHermes's sandbox, including the points where I got stuck in practice.
The NemoHermes setup procedure itself is covered in a previous article. This article starts from the state where NemoHermes is already installed and the sandbox is running.
Goal for This Article
We'll retrieve PR information from a private repository using NemoHermes's terminal tool. The key point is that the actual GitHub token value never enters the sandbox.
The overall flow is as follows.
The request sent out by Python inside the sandbox carries a placeholder, and at the moment it leaves the sandbox, OpenShell's egress proxy replaces the placeholder with the real token. This means raw tokens will never appear in the agent's logs or scripts.
How the OpenShell Provider Holds the Token
The OpenShell provider is a mechanism for managing credentials used when AI agents or sandboxes access external services. By registering a GitHub token or LLM API key as a provider, you can avoid placing raw credentials in the sandbox.
With Providers v2, in addition to credentials, the endpoints, binaries, and network policies used by that provider are also bundled on the provider side. When you attach a GitHub provider to a sandbox, not only is the GITHUB_TOKEN credential passed, but the permission to communicate with api.github.com and the constraints on which binaries may use it also come down together as policies in the _provider_* namespace. The existing sandbox's own policies remain intact, and the two layers overlap to determine the final access permissions. If you want to tighten policies later, keeping this "2 layers overlapping" premise in mind will help avoid confusion.
From NemoHermes's perspective, the environment variable GITHUB_TOKEN contains a placeholder like openshell:resolve:env:..._GITHUB_TOKEN. It's not the actual token value, but a marker that OpenShell resolves at egress time. When having an AI agent read internal repositories, whether this boundary exists or not makes quite a significant difference.
Note that while a placeholder is not a raw token, it is a handle for credential resolution. In this article as well, we won't show the full value and will mask it as openshell:resolve:env:..._GITHUB_TOKEN. From what I verified locally, this placeholder value appears to be tied to the credential name (here, GITHUB_TOKEN), and the string didn't change even when recreating the provider with the same name. It's quietly convenient that you don't need to rewrite the .env you wrote on the agent side when you want to revisit the provider configuration later.
Prerequisites
This article starts from the following state. The sandbox name will be nemohermes-demo.
- NemoHermes is installed and the sandbox is running
- The
openshellcommand is available on the host side - GitHub CLI (
gh) on the host side is authenticated to read private repositories
Confirm the GitHub CLI authentication status on the host side.
gh auth status
Creating a GitHub Provider
First, register the GitHub token as an OpenShell provider. --from-existing is an option that reads the value in the environment variable GITHUB_TOKEN and registers it, and here we're passing the output of gh auth token directly.
GITHUB_TOKEN="$(gh auth token)" \
openshell provider create --name nemohermes-demo-github --type github --from-existing
The token registered here is held on the OpenShell gateway side. There's no need to write it to files or .env inside the sandbox.
Confirm that it was registered.
openshell provider list
NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS
nemohermes-demo-github github 1 0
Enabling Providers v2 and Attaching to the Sandbox
Simply creating a provider doesn't link it to an existing sandbox. Enable Providers v2 and then attach it to the target sandbox.
openshell settings set --global --key providers_v2_enabled --value true
✓ Set global setting providers_v2_enabled=true (revision 1)
At the time of verification, Providers v2 is an opt-in feature that must be explicitly enabled through settings like this. Please note that behavior may change in future versions.
Let's also confirm that the GitHub provider profile is visible.
openshell provider list-profiles
Available Provider Profiles:
INFERENCE
nvidia NVIDIA endpoints: 1 inference
AGENT
claude-code Claude Code endpoints: 3 inference
SOURCE CONTROL
github GitHub endpoints: 2
GitHub is visible as a source control provider. Attach it to the target sandbox.
openshell sandbox provider attach nemohermes-demo nemohermes-demo-github
✓ Attached provider nemohermes-demo-github to sandbox nemohermes-demo
Confirm the attached providers.
openshell sandbox provider list nemohermes-demo
NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS
nemohermes-demo-github github 1 0
Confirming the Placeholder Inside the Sandbox
Let's confirm how the attached credential appears from inside the sandbox.
openshell sandbox exec -n nemohermes-demo -- \
sh -lc 'printf "%s\n" "$GITHUB_TOKEN"'
openshell:resolve:env:..._GITHUB_TOKEN
What's in GITHUB_TOKEN is not the actual token value but a placeholder. The format in the official documentation is openshell:resolve:env:<KEY>, and locally I saw a value with what appeared to be an internal ID prefix before the key. Either way, we can confirm right here that the raw token has not entered the sandbox.
Two Key Mechanisms in Providers v2 and Hermes Runtime
Now that we can see the placeholder, let's prepare to actually call the GitHub API. Providers v2 and Hermes runtime have a two-stage mechanism for protecting credentials, and understanding each one will make the subsequent steps go smoothly.
Route GitHub API Access Through Permitted Binaries
If you try to call the GitHub API with curl from inside the sandbox, it gets blocked at OpenShell's proxy before authentication even happens.
curl: (56) CONNECT tunnel failed, response 403
This is not a credential error but a policy deny before reaching the GitHub API. Looking at the deny reason for blocked requests in openshell term, you can see that api.github.com is not included in the endpoints permitted for curl. In the NemoHermes sandbox I tested, the only binaries that the GitHub policy permitted to access api.github.com were /usr/bin/git and /opt/hermes/.venv/bin/python.
With Providers v2, even which binary may use an endpoint is determined by the provider's policy. It's a design where even with the same token, the executables that can use it are restricted.
Hermes Strips Credentials from Child Processes of Terminal Tools
The other mechanism is on the Hermes runtime side. While processes launched externally via openshell sandbox exec have the GITHUB_TOKEN placeholder, child processes run by NemoHermes's terminal tool will have GITHUB_TOKEN empty.
When Hermes launches child processes for tools like terminal or execute_code, it intentionally strips environment variables with names corresponding to credentials. GITHUB_TOKEN is registered as a credential for the Skills Hub, so it's a target for exclusion. This is where the behavior comes from where writing GITHUB_TOKEN=... in ~/.hermes/.env results in os.environ["GITHUB_TOKEN"] being empty inside a terminal tool.
This was introduced as a response to GHSA-rhgp-j443-p4rf, playing the role of preventing malicious skills from extracting credentials via child processes. Even with terminal.env_passthrough or required_environment_variables in skill frontmatter, variables matching credential names cannot be passed through. This is not something to be configured away—it's a security boundary to be respected.
This means that even a placeholder will be stripped if it keeps the name GITHUB_TOKEN. This is where the _HERMES_FORCE_ prefix comes in.
Passing Through with the Standard Name Using the _HERMES_FORCE_ Prefix
Hermes provides an escape hatch for legitimately passing through this credential scrub. If you prefix an environment variable name with _HERMES_FORCE_, the prefix is stripped when launching child processes of tools, and it's injected under the original name. Setting _HERMES_FORCE_GITHUB_TOKEN means the child process will see it as GITHUB_TOKEN.
Using this, agent-side code can run while assuming the standard GITHUB_TOKEN. It's helpful that you don't need to change variable names even when GitHub tools and samples are written to read GITHUB_TOKEN.
The configuration is just adding one line to the .env that the sandbox's Hermes reads. For the value, put the placeholder confirmed earlier with openshell sandbox exec, not the raw token.
openshell sandbox exec -n nemohermes-demo -- sh -lc '
ph="$GITHUB_TOKEN"
grep -v "^_HERMES_FORCE_GITHUB_TOKEN=" /sandbox/.hermes/.env 2>/dev/null > /sandbox/.hermes/.env.tmp || true
printf "_HERMES_FORCE_GITHUB_TOKEN=%s\n" "$ph" >> /sandbox/.hermes/.env.tmp
mv /sandbox/.hermes/.env.tmp /sandbox/.hermes/.env
'
This reads the placeholder from GITHUB_TOKEN and writes it as _HERMES_FORCE_GITHUB_TOKEN, replacing any existing line with the same name. The important point is that we're writing the placeholder, not the raw token.
Since .env is loaded when Hermes starts, open a new session or restart the agent after making changes for them to take effect.
# Example of running from the agent's terminal tool
printenv GITHUB_TOKEN
If the output returns a placeholder starting with openshell:resolve:env:, it's working.
At this point, Python inside the terminal tool can read the placeholder via os.environ["GITHUB_TOKEN"]. From here, all that's left is to put that placeholder in a Bearer token and call the GitHub API.
Preparing a Python Script for Reading PRs
Let's prepare a Python script to run from NemoHermes's terminal tool.
import json
import os
import sys
import urllib.request
repo = os.environ.get("GH_REPO", "owner/repo")
number = os.environ.get("PR_NUMBER", "1")
token = os.environ.get("GITHUB_TOKEN")
if not token:
print("ERROR: GITHUB_TOKEN is missing from this process environment.")
print("Expected an OpenShell placeholder such as openshell:resolve:env:..._GITHUB_TOKEN")
sys.exit(2)
if not token.startswith("openshell:resolve:env:"):
print("WARNING: GITHUB_TOKEN does not look like an OpenShell placeholder.")
print("Do not continue if this is a raw token in a demo/logging context.")
url = f"https://api.github.com/repos/{repo}/pulls/{number}"
req = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github+json",
"Authorization": "Bearer " + token,
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "nemohermes-demo",
},
)
with urllib.request.urlopen(req, timeout=20) as resp:
data = json.loads(resp.read().decode())
summary = {
"status": "ok",
"number": data.get("number"),
"title": data.get("title"),
"state": data.get("state"),
"html_url": data.get("html_url"),
"private": data.get("head", {}).get("repo", {}).get("private"),
"changed_files": data.get("changed_files"),
"additions": data.get("additions"),
"deletions": data.get("deletions"),
}
print(json.dumps(summary, ensure_ascii=False, indent=2))
The script doesn't request a raw token. It only includes a guard that outputs a warning when a value that doesn't look like a placeholder comes in.
Running from NemoHermes
Place github_read_pr.py using NemoHermes's terminal tool and execute it. Since _HERMES_FORCE_GITHUB_TOKEN is in .env, there's no need to explicitly pass GITHUB_TOKEN. The agent simply runs Python as usual.
GH_REPO='your-org/your-private-repo' \
PR_NUMBER='12' \
/opt/hermes/.venv/bin/python github_read_pr.py
In actual testing, we were able to retrieve PR information from a private repository.
{
"status": "ok",
"number": 12,
"title": "PR Title",
"state": "open",
"html_url": "https://github.com/your-org/your-private-repo/pull/12",
"private": true,
"changed_files": 224,
"additions": 3645,
"deletions": 2223
}
We retrieved PR metadata from a private: true repository. Throughout this process, all the sandbox's processes saw was the placeholder, and the raw token never left the OpenShell gateway.
Turning It Into a NemoHermes Skill
Since this procedure seemed likely to be reused, I also extracted it as a NemoHermes skill. By making it a skill, the agent will remember the rules from this time—"don't request raw tokens" and "call GitHub API with Python"—whenever it receives a GitHub-related request.
Example placement location.
~/.hermes/skills/nvidia/nemohermes-github-provider/SKILL.md
Minimal SKILL.md configuration (click to expand)
---
name: nemohermes-github-provider
description: Use when NemoHermes needs to read GitHub Issues or Pull Requests through OpenShell Providers v2 without exposing raw GitHub tokens.
version: 1.0.0
license: MIT
metadata:
hermes:
tags: [nemohermes, openshell, github, providers-v2, credentials]
---
# NemoHermes GitHub Provider
## Overview
Use this skill when NemoHermes needs to read GitHub Issues or Pull Requests from a private repository through OpenShell Providers v2.
The agent must not ask for a raw GitHub token. Use the OpenShell placeholder as `GITHUB_TOKEN` and call the GitHub REST API with `/opt/hermes/.venv/bin/python`.
Expected placeholder shape:
```text
openshell:resolve:env:..._GITHUB_TOKEN
```
## Rules
- Do not ask the user for a raw GitHub token.
- Do not print or save raw credentials.
- Treat `GITHUB_TOKEN` as an OpenShell placeholder.
- Do not use `curl` for GitHub API calls in this sandbox.
- Use `/opt/hermes/.venv/bin/python` for GitHub REST API calls.
- If `GITHUB_TOKEN` is missing, ask for the OpenShell placeholder, not the raw token.
## Read a pull request
Create `github_read_pr.py` and run it with `GH_REPO` and `PR_NUMBER`. `GITHUB_TOKEN` is injected into the process environment via the host-side `_HERMES_FORCE_GITHUB_TOKEN` setting, so do not pass it explicitly.
```bash
GH_REPO='owner/repo' \
PR_NUMBER='1' \
/opt/hermes/.venv/bin/python github_read_pr.py
```
The Python script should read `GITHUB_TOKEN` from the environment, use `Authorization: Bearer $GITHUB_TOKEN`, and summarize only task-relevant fields such as title, state, URL, changed file count, additions, deletions, and body.
## Troubleshooting
If `GITHUB_TOKEN` is missing from the process environment, stop and report it. Do not request the raw token, and do not read it from `/proc`. The fix is on the host side: set `_HERMES_FORCE_GITHUB_TOKEN=<placeholder>` in `/sandbox/.hermes/.env` and restart the agent so the placeholder is injected under the standard name.
If `curl` returns a proxy or policy 403, retry with `/opt/hermes/.venv/bin/python` instead of widening policy.
There are only three rules being communicated: don't request the actual GitHub token value, treat the placeholder as GITHUB_TOKEN, and call the GitHub API from /opt/hermes/.venv/bin/python.
Deploy to Sandbox Using Skill Install
When bringing a skill directory created on the host side into NemoHermes's sandbox, use the NemoClaw CLI's skill install rather than manually copying files. It handles SKILL.md frontmatter validation, uploading while preserving subdirectory structure, and post-install processing all together.
nemohermes nemohermes-demo skill install ./skills/nemohermes-github-provider
✓ Validated SKILL.md (name: nemohermes-github-provider, 3 files)
✓ Uploaded 3 file(s) to sandbox
Restart the agent gateway to pick up the new skill.
✓ Skill 'nemohermes-github-provider' installed
The skill is placed under /sandbox/.hermes/skills/ inside the sandbox and appears as a local skill in hermes skills list. Opening a new session will make it recognized from the agent side's skills_list tool as well.
Summary
The OpenShell provider holds the token, and the Hermes runtime strips credential-named env variables from child processes. With credentials handled in these two stages—provider and runtime—the agent only ever sees the placeholder, and since OpenShell resolves it to the real token at egress time, raw tokens never appear in the agent's logs or scripts.
This time we used a GitHub token as the example, but the Providers v2 framework itself can be extended as-is to LLM API keys and internal SaaS credentials. As we saw with openshell provider list-profiles, INFERENCE and AGENT provider profiles are also available from the start, and once you build a configuration that doesn't give the agent raw credentials, it's convenient to be able to add new external services following the same flow.
Personally, I think this is a quite manageable configuration both as a first step when having AI agents read internal repositories, and as a template when revisiting credential management.
