diff --git a/.github/workflows/voltage-agent-images.yml b/.github/workflows/voltage-agent-images.yml index fa30354d16a..c482d3e2c12 100644 --- a/.github/workflows/voltage-agent-images.yml +++ b/.github/workflows/voltage-agent-images.yml @@ -284,6 +284,7 @@ jobs: python3 test-codex-login.py "$IMAGE" python3 -O test-profile.py "$IMAGE" python3 test-cluster.py "$IMAGE" + python3 test-environment.py "$IMAGE" python3 test-skills.py "$IMAGE" # main: resolve engineering-skills master once, so every platform image of diff --git a/voltage/images/buzz-agent/Dockerfile b/voltage/images/buzz-agent/Dockerfile index 267ef6429c1..86a91eb7be0 100644 --- a/voltage/images/buzz-agent/Dockerfile +++ b/voltage/images/buzz-agent/Dockerfile @@ -98,6 +98,7 @@ COPY --chmod=0644 voltage/images/buzz-agent/cluster.md /etc/buzz-agent/cluster.m # not change it; profile.py links each skill into the harnesses. # hadolint ignore=DL3022 COPY --from=engineering-skills . /opt/engineering-skills/ +COPY --chmod=0644 voltage/images/buzz-agent/environment.md /etc/buzz-agent/environment.md COPY --chmod=0755 voltage/images/buzz-agent/heavy-bin/ /opt/buzz-heavy/bin/ COPY --chmod=0755 voltage/images/buzz-agent/gh.sh /usr/local/bin/gh COPY --chmod=0755 voltage/images/buzz-agent/via.sh /usr/local/bin/via diff --git a/voltage/images/buzz-agent/Dockerfile.dockerignore b/voltage/images/buzz-agent/Dockerfile.dockerignore index efc42168e34..a021be5b0b5 100644 --- a/voltage/images/buzz-agent/Dockerfile.dockerignore +++ b/voltage/images/buzz-agent/Dockerfile.dockerignore @@ -99,3 +99,4 @@ voltage/ !voltage/images/buzz-agent/subagent.py !voltage/images/buzz-agent/subagents.md !voltage/images/buzz-agent/cluster.md +!voltage/images/buzz-agent/environment.md diff --git a/voltage/images/buzz-agent/README.md b/voltage/images/buzz-agent/README.md index e6ae73c858c..7222b524d11 100644 --- a/voltage/images/buzz-agent/README.md +++ b/voltage/images/buzz-agent/README.md @@ -45,6 +45,7 @@ python3 test-cache.py buzz-agent:local python3 -O test-profile.py buzz-agent:local python3 test-cluster.py buzz-agent:local python3 test-skills.py buzz-agent:local +python3 test-environment.py buzz-agent:local ``` To build with the engineering skills, replace `--none` with a local checkout: @@ -151,6 +152,8 @@ The bootstrap exports `KUBECONFIG` and appends [the diagnostic guide](cluster.md It skips configuration when neither token is mounted, and rejects credentials for another developer namespace. It performs no cluster API calls during startup. +Every session and worker also gets [the environment guide](environment.md), appended to the same instructions whether or not cluster credentials are mounted. It covers how to find enrolled services with `via capabilities`, how to detect a Sandbox, whose identity a `via` service uses, running untrusted code in a credentialed Pod, and where to report vulnerabilities. + The staging Terraform root owns the observer RBAC, worker projection, and API egress policy. Diagnostic permissions cover reads across staging namespaces, including logs, events, metrics, routing, and Argo CD state. They exclude Secrets, exec, attach, port forwarding, node/service proxies, and mutations. diff --git a/voltage/images/buzz-agent/environment.md b/voltage/images/buzz-agent/environment.md new file mode 100644 index 00000000000..fed29a45b05 --- /dev/null +++ b/voltage/images/buzz-agent/environment.md @@ -0,0 +1,19 @@ + +## Working in the Sandbox + +Before you say you lack access to a service, run `via capabilities` and `buzz --help`. +Enrolled services (for example Linear, Grafana and Argo CD) are reached with the capability that `via capabilities` lists for the service, usually `via api ...`, not with a dedicated CLI or an environment variable. +Depot is different: the image wires it into build caches (for example `depot gocache`), so it has no `via depot api`; check the cache before calling it unavailable. +When you report that something is unavailable, say which places you checked. + +Check that you are in a Sandbox with `KUBERNETES_SERVICE_HOST`, not with the path `/var/run/secrets/kubernetes.io`, which is not mounted in this Pod. + +A service reached through `via` may act as its own app identity or as a user, depending on how it is configured. +When you report a change made through it, such as a ticket you created, name the identity it used, or say that you do not know. + +This Pod holds live credentials: a cluster token, your Buzz key, and `via` access. +Do not build, test, install dependencies for, or run code from a repository you do not trust. +Read its source, or ask the person you work for first. + +Keep details of an unfixed vulnerability out of public issues, pull requests, commit messages and public channels. +Report them only where the person you work for has chosen, such as a private channel or the company tracker. diff --git a/voltage/images/buzz-agent/profile.py b/voltage/images/buzz-agent/profile.py index e73709b948b..3a649d1dc69 100644 --- a/voltage/images/buzz-agent/profile.py +++ b/voltage/images/buzz-agent/profile.py @@ -279,9 +279,10 @@ def bootstrap(): env['CLAUDE_CODE_OAUTH_TOKEN'] = profile['models']['claude_oauth_token'] # The agent's tool shell starts from a cleared environment. Forward the model # credentials so tools it runs, such as buzz-agent-review, reach the runtime; - # they stay in process environments and are never written to files. + # they stay in process environments and are never written to files. The + # Kubernetes host is not secret; the environment guide tells agents to check it. env['BUZZ_ACP_MCP_FORWARD_ENV'] = ','.join( - name for name in (*INHERITED_MODEL_VARIABLES, 'CODEX_HOME') if env.get(name)) + name for name in (*INHERITED_MODEL_VARIABLES, 'CODEX_HOME', 'KUBERNETES_SERVICE_HOST') if env.get(name)) step('configuring Git commit signing') command('/usr/local/bin/buzz-agent-account', 'init', env=env) return env @@ -304,6 +305,7 @@ def restore_checkpoint(): SUBAGENT_GUIDE = Path('/etc/buzz-agent/subagents.md') CLUSTER_CREDENTIALS = Path('/var/run/secrets/buzz-cluster') CLUSTER_GUIDE = Path('/etc/buzz-agent/cluster.md') +ENVIRONMENT_GUIDE = Path('/etc/buzz-agent/environment.md') def install_cluster_access(env): @@ -336,6 +338,15 @@ def install_cluster_access(env): handle.write(guide) +def install_environment_guide(env): + """Give every Sandbox session the same rules for the services and credentials in its Pod.""" + guide = ENVIRONMENT_GUIDE.read_text() + for target in (Path('/home/agent/.claude/CLAUDE.md'), Path(env.get('CODEX_HOME', '/home/agent/.codex')) / 'AGENTS.md'): + target.parent.mkdir(parents=True, exist_ok=True) + with open(target, 'a') as handle: + handle.write(guide) + + def install_subagent_guide(env): """Tell Claude Code and Codex how to delegate when this session may spawn workers.""" if not (SUBAGENT_CREDENTIALS / 'token').exists(): @@ -403,6 +414,8 @@ def main(): install_cluster_access(env) step('installing engineering skills') install_skills(env) + step('installing the environment guide') + install_environment_guide(env) step('starting the worker') worker = ['/usr/local/bin/buzz-agent-cache', '/usr/local/bin/buzz-agent-worker'] os.execvpe(worker[0], worker, env) @@ -412,6 +425,8 @@ def main(): restore_checkpoint() step('configuring staging cluster diagnostics') install_cluster_access(env) + step('installing the environment guide') + install_environment_guide(env) step('installing the sub-agent guide') install_subagent_guide(env) step('installing engineering skills') diff --git a/voltage/images/buzz-agent/test-environment.py b/voltage/images/buzz-agent/test-environment.py new file mode 100644 index 00000000000..cad14ee76b4 --- /dev/null +++ b/voltage/images/buzz-agent/test-environment.py @@ -0,0 +1,56 @@ +"""Check that every session receives the environment guide, with or without cluster credentials.""" +import subprocess +import sys + +SCRIPT = r''' +import os, runpy, sys, tempfile +from pathlib import Path + +profile = runpy.run_path('/usr/local/bin/buzz-agent-profile', run_name='probe') +install = profile['install_environment_guide'] +settings = install.__globals__ +home = Path(tempfile.mkdtemp(prefix='environment-test-', dir='/home/agent')) +claude = Path('/home/agent/.claude/CLAUDE.md') + +def require(condition, message): + if not condition: raise RuntimeError(message) + +guide = settings['ENVIRONMENT_GUIDE'].read_text() +require('via capabilities' in guide and 'KUBERNETES_SERVICE_HOST' in guide, 'guide lost its rules') +settings['CLUSTER_CREDENTIALS'] = home / 'no-worker' +settings['SUBAGENT_CREDENTIALS'] = home / 'no-primary' +claude.parent.mkdir(parents=True, exist_ok=True) +claude.write_text('existing instructions\n') +env = {**os.environ, 'CODEX_HOME': str(home / 'codex')} +install(env) +install(env) +require(claude.read_text().startswith('existing instructions\n' + guide), 'Claude Code instructions were replaced or lack the guide') +require(claude.read_text().count('## Working in the Sandbox') == 2, 'each install call should append the guide once') +require((home / 'codex/AGENTS.md').read_text() == guide * 2, 'Codex instructions lack the guide') + +# Bind the production seam: each startup path must install the guide before it launches its child. +main = settings['main'] +for action in ('run', 'work'): + claude.write_text('') + events = [] + class Launched(Exception): pass + def launch(path, argv, env): events.append('launch'); raise Launched() + settings.update( + step=lambda message: None, bootstrap=lambda: env, restore_checkpoint=lambda: None, + install_cluster_access=lambda env: None, install_subagent_guide=lambda env: None, + install_skills=lambda env: None, + install_environment_guide=lambda env, real=install: (events.append('guide'), real(env))) + settings['os'].execvpe = launch + sys.argv = ['profile', action] + try: main() + except Launched: pass + require(events == ['guide', 'launch'], action + ' did not install the guide before launching: ' + repr(events)) + require(claude.read_text() == guide, action + ' did not write the guide') +print('PASS: the guide reaches Claude Code and Codex, and both startup paths install it') +''' + +if __name__ == '__main__': + result = subprocess.run(['docker', 'run', '--rm', '-i', '--network', 'none', + '--entrypoint', 'python3', sys.argv[1] if len(sys.argv) > 1 else 'buzz-agent:local', '-'], + input=SCRIPT, text=True, timeout=90) + raise SystemExit(result.returncode) diff --git a/voltage/images/buzz-agent/test-profile.py b/voltage/images/buzz-agent/test-profile.py index 1ecab3423f3..8a31dc2deeb 100644 --- a/voltage/images/buzz-agent/test-profile.py +++ b/voltage/images/buzz-agent/test-profile.py @@ -112,7 +112,9 @@ def rotate(models, git=None): # Exactly the configured model credentials reach the agent's tool shell. forwarded = set(filter(None, os.environ.get('BUZZ_ACP_MCP_FORWARD_ENV', '').split(','))) models = {k for k, v in EXPECTED.items() if v and k in INHERITED} -if not models <= forwarded or forwarded - models - {'CODEX_HOME'}: +if 'KUBERNETES_SERVICE_HOST' not in forwarded: + raise RuntimeError('Kubernetes host is not forwarded to the tool shell') +if not models <= forwarded or forwarded - models - {'CODEX_HOME', 'KUBERNETES_SERVICE_HOST'}: raise RuntimeError('forwarded tool-shell variables differ from the profile credentials') if subprocess.check_output(['git','config','--global','user.email'], text=True).strip() != 'alice@example.invalid': raise RuntimeError('Git identity missing') @@ -177,6 +179,7 @@ def main(): '--tmpfs', '/tmp:uid=10001,gid=10001,mode=0700,size=128m,exec', '-e', 'AWS_ACCESS_KEY_ID=testing', '-e', 'AWS_SECRET_ACCESS_KEY=testing', '-e', 'AWS_DEFAULT_REGION=us-west-2', '-e', 'AWS_EC2_METADATA_DISABLED=true', + '-e', 'KUBERNETES_SERVICE_HOST=192.0.2.1', '-e', 'AWS_ENDPOINT_URL=http://'+server+':5000', '--entrypoint', 'python3'] result = subprocess.run([*base, '-e', 'FIXTURE_PROFILE='+json.dumps(PROFILE), image, '-'], input=SEED, text=True, capture_output=True, timeout=180)