
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 let an AI agent read GitHub private repositories. But handing over the GitHub token directly feels a bit concerning...
When I think back to my usual setup, the most straightforward approach is writing GITHUB_TOKEN=ghp_... in .env and having the agent read it. Agent-side harnesses like Claude Code and Cursor have been gradually improving their mechanisms for automatically adding to .gitignore, detecting secrets, and restricting how env variables are passed, making accidents like "accidental commits" or "accidental log output" less likely than before.
However, the raw token still sits on the host disk as-is. The agent process can read its contents directly, and there's no way to structurally eliminate the risk of it being passed directly to curl in a tool call, or accidentally displayed on screen during reasoning. Private repository tokens often carry strong permissions including PR merges and Workflow triggers, so leaving them on disk where an agent can freely use them still feels a bit 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.
In this article, I'll walk through the steps to read a PR in a GitHub private repository from a NemoHermes sandbox, including the points where I got stuck in practice.
The NemoHermes installation 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 OpenShell's egress proxy replaces the placeholder with the real token as it leaves the sandbox. This means raw tokens 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 GitHub tokens or LLM API keys as providers, 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 now bundled together 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 under the _provider_* namespace. The sandbox's own existing policies remain intact, and the two layers overlap to determine the final access permissions. If you want to tighten policies later, keeping this "two 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 letting an AI agent read internal repositories, having this boundary or not makes quite a significant difference.
Note that while a placeholder is not a raw token, it is still a handle for credential resolution. In this article as well, we won't show the complete value and will treat it as masked: openshell:resolve:env:..._GITHUB_TOKEN. From what I confirmed locally, this placeholder value appears to be tied to the credential name (here GITHUB_TOKEN), and even if you recreate the provider with the same name, the string doesn't change. It's a subtle but nice benefit that when you want to revisit the provider configuration later, you don't have to rewrite the .env you wrote on the agent side.
Prerequisites
This article starts from the following state. The sandbox name is nemohermes-demo.
- NemoHermes is already 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
Check the GitHub CLI authentication status on the host side.
gh auth status
Creating the GitHub Provider
First, register the GitHub token as an OpenShell provider. --from-existing is an option that reads the value stored in the environment variable GITHUB_TOKEN and registers it; here we're passing the output of gh auth token on the spot.
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 in sandbox files or .env.
Check that the registration succeeded.
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 the behavior may change in future versions.
Let's also check 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
Check 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 check how the attached credential appears from within the sandbox.
openshell sandbox exec -n nemohermes-demo -- \
sh -lc 'printf "%s\n" "$GITHUB_TOKEN"'
openshell:resolve:env:..._GITHUB_TOKEN
What's stored in GITHUB_TOKEN is a placeholder, not the actual token value. The official documentation format is openshell:resolve:env:<KEY>, and locally I saw a value with what appears to be an internal ID prefix prepended to the key. Either way, we can first confirm here that the raw token has not entered the sandbox.
Two Key Mechanisms in Providers v2 and the Hermes Runtime
Now that we've confirmed the placeholder, let's prepare to actually call the GitHub API. Providers v2 and the Hermes runtime have a two-layer mechanism to protect credentials, and understanding each one will help the subsequent steps go smoothly.
Route GitHub API Access Through Permitted Binaries
If you try to call the GitHub API from inside the sandbox using curl, it gets blocked by OpenShell's proxy even before authentication.
curl: (56) CONNECT tunnel failed, response 403
This is not a credential error — it's a policy deny before reaching the GitHub API. Looking at the deny reason for a blocked request in openshell term, you can see that api.github.com is not included in the endpoints permitted for curl. In my local NemoHermes sandbox, the only binaries the GitHub policy permitted to access api.github.com were /usr/bin/git and /opt/hermes/.venv/bin/python.
With Providers v2, "which binary is allowed to use which endpoint" is determined by the provider-side 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 the Terminal Tool
The other aspect is the Hermes runtime's behavior. Processes launched externally via openshell sandbox exec have the GITHUB_TOKEN placeholder, but in child processes run by NemoHermes's terminal tool, GITHUB_TOKEN becomes empty.
When Hermes launches child processes for tools like terminal or execute_code, it intentionally strips environment variables whose names correspond to credentials. GITHUB_TOKEN is registered as a credential for Skills Hub, so it falls under this exclusion. The behavior where writing GITHUB_TOKEN=... in ~/.hermes/.env results in os.environ["GITHUB_TOKEN"] being empty inside a terminal tool stems from this mechanism.
This was introduced as a countermeasure for GHSA-rhgp-j443-p4rf, serving to prevent malicious skills from extracting credentials via child processes. Even with terminal.env_passthrough or skill frontmatter's required_environment_variables, variables corresponding to credential names cannot be passed through. This is not something to be disabled through configuration — it's a security boundary that should be respected.
This means that even a placeholder will be stripped if it has the name GITHUB_TOKEN. This is where the _HERMES_FORCE_ prefix comes in.
Passing Under the Standard Name with the _HERMES_FORCE_ Prefix
Hermes provides an escape hatch to pass through this credential scrub through proper procedure. If you prefix an environment variable name with _HERMES_FORCE_, when launching tool child processes the prefix is removed and it gets injected under the original name. If you pass _HERMES_FORCE_GITHUB_TOKEN, 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 when GitHub tools and samples are written assuming they'll read GITHUB_TOKEN, you don't have to change the variable name.
Configuration is just adding one line to the .env that the sandbox's Hermes reads. The value should be the placeholder confirmed earlier via openshell sandbox exec, not a 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, writes it as _HERMES_FORCE_GITHUB_TOKEN, and replaces any existing line with the same name. The important point is that we're writing the placeholder, not the raw token.
Since .env is read at Hermes startup, after making changes you'll need to open a new session or restart the agent for it to take effect.
# Image of what to have the agent's terminal tool execute
printenv GITHUB_TOKEN
If the output returns a placeholder starting with openshell:resolve:env:, it's successful.
At this point, Python inside the terminal tool can read the placeholder via os.environ["GITHUB_TOKEN"]. All that's left is to put that placeholder in a Bearer token and call the GitHub API.
Preparing the Python Script to Read 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 ask for a raw token. It only includes a guard that outputs a warning when a value that doesn't look like a placeholder is received.
Running from NemoHermes
Place github_read_pr.py and run it using NemoHermes's terminal tool. Since _HERMES_FORCE_GITHUB_TOKEN is in .env, there's no need to explicitly pass GITHUB_TOKEN. The agent just runs Python normally.
GH_REPO='your-org/your-private-repo' \
PR_NUMBER='12' \
/opt/hermes/.venv/bin/python github_read_pr.py
In actual testing, I was 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
}
PR metadata was retrieved from a private: true repository. Throughout this process, all the sandbox processes ever saw was the placeholder, and the raw token never left the OpenShell gateway.
Turning This Into a NemoHermes Skill
Since I expected to reuse these steps, I also extracted them into a NemoHermes skill. By making it a skill, whenever the agent receives a GitHub-related request, it will remember each time the rules from this session: "don't ask for a raw token" and "call the GitHub API via Python."
Example placement:
~/.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 ask for the actual GitHub token value, treat the placeholder as GITHUB_TOKEN, and call the GitHub API from /opt/hermes/.venv/bin/python.
Deploy to the Sandbox Using Skill Install
When bringing a skill directory created on the host side into the NemoHermes sandbox, use the NemoClaw CLI's skill install rather than manually copying files. It handles everything including SKILL.md frontmatter validation, upload while preserving subdirectory structure, and post-install processing.
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 makes it recognizable 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 this two-layer approach of provider and runtime handling credentials, the agent only ever sees a 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 seen with openshell provider list-profiles, INFERENCE and AGENT provider profiles are available out of the box, and once you set up the configuration where agents don't hold raw credentials, adding new external services follows the same flow — which is quite convenient.
Personally, I think this is a fairly manageable configuration both as a first step when letting AI agents read internal repositories, and as a template when revisiting credential management.
