Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/build-image.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Build and Push Container Image

on:
push:
branches: [main]
pull_request:
branches: [main]

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
36 changes: 36 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
FROM registry.access.redhat.com/ubi9/python-312

# Install system dependencies required by operator tooling
USER 0
RUN dnf install -y \
git \
make && \
# Install Go toolchain
curl -fsSL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz && \
# Install GitHub CLI
dnf install -y 'dnf-command(config-manager)' && \
dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo && \
dnf install -y gh && \
dnf clean all

ENV PATH="/usr/local/go/bin:${PATH}"

WORKDIR /app

# Install Python dependencies
COPY server/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy server code and config
COPY server/server.py server/config.json ./

# Copy plugins directory
# server.py resolves: Path(__file__).parent.parent / "plugins" / "oape"
# With __file__=/app/server.py, parent.parent=/, so it expects /plugins/oape
COPY plugins /plugins

USER 1001

EXPOSE 8000

CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
67 changes: 67 additions & 0 deletions deploy/deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: oape-server-config
data:
CLAUDE_CODE_USE_VERTEX: "1"
CLOUD_ML_REGION: "us-east5"
ANTHROPIC_VERTEX_PROJECT_ID: "[gcp-project-id]"
ANTHROPIC_MODEL: "claude-opus-4-6"
---
apiVersion: v1
kind: Secret
metadata:
name: gcloud-adc-secret
type: Opaque
stringData:
# Replace with ~/.config/gcloud/application_default_credentials.json
# that can authenticate with GCP project.
application_default_credentials.json: '{}'
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: oape-server
labels:
app: oape-server
spec:
replicas: 1
selector:
matchLabels:
app: oape-server
template:
metadata:
labels:
app: oape-server
spec:
containers:
- name: oape-server
image: ghcr.io/shiftweek/oape-ai-e2e:latest
ports:
- containerPort: 8000
envFrom:
- configMapRef:
name: oape-server-config
env:
- name: GOOGLE_APPLICATION_CREDENTIALS
value: /secrets/gcloud/application_default_credentials.json
volumeMounts:
- name: gcloud-adc
mountPath: /secrets/gcloud
readOnly: true
volumes:
- name: gcloud-adc
secret:
secretName: gcloud-adc-secret
Comment on lines +21 to +55
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add a securityContext and a writable /tmp mount.

The deployment lacks a hardened securityContext. If you enable a read-only root filesystem, /tmp will need an emptyDir for logging.

🔒 Proposed hardening
 spec:
   template:
     metadata:
       labels:
         app: oape-server
     spec:
+      securityContext:
+        runAsNonRoot: true
+        runAsUser: 1001
+        seccompProfile:
+          type: RuntimeDefault
       containers:
         - name: oape-server
           image: ghcr.io/shiftweek/oape-ai-e2e:latest
+          securityContext:
+            allowPrivilegeEscalation: false
+            readOnlyRootFilesystem: true
+            capabilities:
+              drop: ["ALL"]
           ports:
             - containerPort: 8000
           envFrom:
             - configMapRef:
                 name: oape-server-config
           env:
             - name: GOOGLE_APPLICATION_CREDENTIALS
               value: /secrets/gcloud/application_default_credentials.json
           volumeMounts:
             - name: gcloud-adc
               mountPath: /secrets/gcloud
               readOnly: true
+            - name: tmp
+              mountPath: /tmp
       volumes:
         - name: gcloud-adc
           secret:
             secretName: gcloud-adc-secret
+        - name: tmp
+          emptyDir: {}
🧰 Tools
🪛 Checkov (3.2.334)

[medium] 21-56: Containers should not run with allowPrivilegeEscalation

(CKV_K8S_20)


[medium] 21-56: Minimize the admission of root containers

(CKV_K8S_23)

🪛 Trivy (0.69.1)

[error] 36-49: Root file system is not read-only

Container 'oape-server' of Deployment 'oape-server' should set 'securityContext.readOnlyRootFilesystem' to true

Rule: KSV-0014

Learn more

(IaC/Kubernetes)


[error] 36-49: Default security context configured

container oape-server in default namespace is using the default security context

Rule: KSV-0118

Learn more

(IaC/Kubernetes)


[error] 34-53: Default security context configured

deployment oape-server in default namespace is using the default security context, which allows root privileges

Rule: KSV-0118

Learn more

(IaC/Kubernetes)

🤖 Prompt for AI Agents
In `@deploy/deployment.yaml` around lines 21 - 55, Add a hardened securityContext
to the Deployment's container (name: oape-server) by setting runAsNonRoot: true,
runAsUser to a non-root UID, and enforcing readOnlyRootFilesystem: true while
dropping all capabilities; then provide a writable /tmp by adding a volumeMount
for mountPath: /tmp (readOnly: false) using an emptyDir volume (e.g., name:
tmp-dir) and add the corresponding volumes entry with emptyDir in the Pod spec
so the container has a writable temp directory despite a read-only root
filesystem.

---
apiVersion: v1
kind: Service
metadata:
name: oape-server
spec:
selector:
app: oape-server
ports:
- port: 8000
targetPort: 8000
type: ClusterIP
13 changes: 13 additions & 0 deletions server/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@

{
"claude_allowed_tools": [
"Bash(*)",
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"WebFetch",
"Task"
]
}
3 changes: 3 additions & 0 deletions server/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fastapi>=0.115.0
uvicorn>=0.32.0
claude-agent-sdk>=0.1.0
148 changes: 148 additions & 0 deletions server/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""
FastAPI server that exposes the /oape:api-implement Claude Code skill
via the Claude Agent SDK.

Usage:
uvicorn api.server:app --reload
Comment on lines +5 to +6
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Correct the module path in the usage snippet.

Line 6 points to uvicorn api.server:app, but the module path here is server.server. This will fail unless an api package exists.

🛠️ Proposed fix
-    uvicorn api.server:app --reload
+    uvicorn server.server:app --reload
🤖 Prompt for AI Agents
In `@server/server.py` around lines 5 - 6, The usage snippet references the wrong
module path; replace "uvicorn api.server:app --reload" with the correct module
path "uvicorn server.server:app --reload" (or update to whichever importable
module exposes the FastAPI app) and ensure any other docs or comments that
mention "api.server" are updated to "server.server" so the uvicorn command
imports the app correctly.


Endpoint:
GET /api-implement?ep_url=<enhancement-pr-url>&cwd=<operator-repo-path>
"""

import json
import logging
import os
import re
import traceback
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query
from claude_agent_sdk import (
query,
ClaudeAgentOptions,
AssistantMessage,
ResultMessage,
TextBlock,
)


with open("config.json") as cf:
config_json_str = cf.read()
CONFIGS = json.loads(config_json_str)
Comment on lines +28 to +30
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Config file path is relative to CWD, not the module location.

If the server is started from a directory other than server/, this will fail to find config.json. The docstring suggests running uvicorn api.server:app which would typically be run from the repo root.

🐛 Proposed fix to resolve path relative to module
-with open("config.json") as cf:
+with open(Path(__file__).parent / "config.json") as cf:
     config_json_str = cf.read()
 CONFIGS = json.loads(config_json_str)
🤖 Prompt for AI Agents
In `@server/server.py` around lines 28 - 30, The current open("config.json") call
loads CONFIGS from a path relative to the CWD which breaks when the server is
started from the repo root; change the file lookup in server.py to resolve the
config.json path relative to the module file (use __file__ or
pathlib.Path(__file__).resolve().parent) before opening so CONFIGS and
config_json_str always read the module-local config.json regardless of working
directory.



app = FastAPI(
title="OAPE Operator Feature Developer",
description="Invokes the /oape:api-implement Claude Code command to generate "
"controller/reconciler code from an OpenShift enhancement proposal.",
version="0.1.0",
)

EP_URL_PATTERN = re.compile(
r"^https://github\.com/openshift/enhancements/pull/\d+/?$"
)

# Resolve the plugin directory (repo root) relative to this file.
# The SDK expects the path to the plugin root (containing .claude-plugin/).
PLUGIN_DIR = str(Path(__file__).resolve().parent.parent / "plugins" / "oape")
print(PLUGIN_DIR)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove debug print statement.

print(PLUGIN_DIR) will output to stdout on every module import. This should be removed or converted to a debug-level log statement.

🧹 Proposed fix
 PLUGIN_DIR = str(Path(__file__).resolve().parent.parent / "plugins" / "oape")
-print(PLUGIN_DIR)
+# Optionally log at debug level:
+# logging.getLogger(__name__).debug("PLUGIN_DIR=%s", PLUGIN_DIR)
🤖 Prompt for AI Agents
In `@server/server.py` at line 47, Remove the stray debug print by deleting the
print(PLUGIN_DIR) call in server.py (it runs on every import); if you need that
info, replace it with a logger.debug call instead (use the module logger or
obtain one via logging.getLogger(__name__)) so it no longer writes to stdout at
import time.


CONVERSATION_LOG = Path("/tmp/conversation.log")

conv_logger = logging.getLogger("conversation")
conv_logger.setLevel(logging.INFO)
_handler = logging.FileHandler(CONVERSATION_LOG)
_handler.setFormatter(logging.Formatter("%(message)s"))
conv_logger.addHandler(_handler)
Comment on lines +49 to +55
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid a fixed log file in /tmp.

Line 52 uses a hard-coded /tmp path, which is world-writable and can be clobbered. Make it configurable and ensure a private directory is created.

🔒 Proposed hardening
-CONVERSATION_LOG = Path("/tmp/conversation.log")
+CONVERSATION_LOG = Path(os.getenv("CONVERSATION_LOG", "/tmp/oape/conversation.log"))
+CONVERSATION_LOG.parent.mkdir(parents=True, exist_ok=True)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
CONVERSATION_LOG = Path("/tmp/conversation.log")
conv_logger = logging.getLogger("conversation")
conv_logger.setLevel(logging.INFO)
_handler = logging.FileHandler(CONVERSATION_LOG)
_handler.setFormatter(logging.Formatter("%(message)s"))
conv_logger.addHandler(_handler)
CONVERSATION_LOG = Path(os.getenv("CONVERSATION_LOG", "/tmp/oape/conversation.log"))
CONVERSATION_LOG.parent.mkdir(parents=True, exist_ok=True)
conv_logger = logging.getLogger("conversation")
conv_logger.setLevel(logging.INFO)
_handler = logging.FileHandler(CONVERSATION_LOG)
_handler.setFormatter(logging.Formatter("%(message)s"))
conv_logger.addHandler(_handler)
🧰 Tools
🪛 Ruff (0.15.0)

[error] 52-52: Probable insecure usage of temporary file or directory: "/tmp/conversation.log"

(S108)

🤖 Prompt for AI Agents
In `@server/server.py` around lines 52 - 58, CONVERSATION_LOG is hard-coded to
/tmp which is unsafe; make the log location configurable (e.g., read from an env
var or config value) and ensure the directory is created with private
permissions before creating the FileHandler. Update the code that defines
CONVERSATION_LOG to derive the Path from a config/env (falling back to a safe
default inside the application data dir), create the parent directory with mode
0o700 (os.makedirs(path, exist_ok=True) + Path.chmod) and only then instantiate
conv_logger, _handler and add the FileHandler so the file is written in a
non-world-writable, private directory. Ensure conv_logger and _handler continue
to be used as before.



@app.get("/api-implement")
async def api_implement(
ep_url: str = Query(
...,
description="GitHub PR URL for the OpenShift enhancement proposal "
"(e.g. https://github.com/openshift/enhancements/pull/1234)",
),
cwd: str = Query(
default="",
description="Absolute path to the operator repository where code "
"will be generated. Defaults to the current working directory.",
),
):
"""Generate controller/reconciler code from an enhancement proposal."""

# --- Validate EP URL ---
if not EP_URL_PATTERN.match(ep_url.rstrip("/")):
raise HTTPException(
status_code=400,
detail=(
"Invalid enhancement PR URL. "
"Expected format: https://github.com/openshift/enhancements/pull/<number>"
),
)

# --- Resolve working directory ---
working_dir = cwd if cwd else os.getcwd()
if not os.path.isdir(working_dir):
raise HTTPException(
status_code=400,
detail=f"The provided cwd is not a valid directory: {working_dir}",
)

# --- Build SDK options ---
options = ClaudeAgentOptions(
system_prompt=(
"You are an OpenShift operator code generation assistant. "
"Execute the oape:api-implement plugin with the provided EP URL. "
),
cwd=working_dir,
permission_mode="bypassPermissions",
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Document or make bypassPermissions mode configurable.

Setting permission_mode="bypassPermissions" disables permission checks for the agent, which could allow unintended filesystem or tool access. Consider:

  1. Making this configurable via environment variable or config
  2. Documenting the security implications
  3. Using a more restrictive mode in production
🔒 Proposed fix to make permission mode configurable
     options = ClaudeAgentOptions(
         system_prompt=(
             "You are an OpenShift operator code generation assistant. "
             "Execute the oape:api-implement plugin with the provided EP URL. "
         ),
         cwd=working_dir,
-        permission_mode="bypassPermissions",
+        permission_mode=CONFIGS.get("permission_mode", "default"),
         allowed_tools=CONFIGS['claude_allowed_tools'],
         plugins=[{"type": "local", "path": PLUGIN_DIR}],
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
permission_mode="bypassPermissions",
options = ClaudeAgentOptions(
system_prompt=(
"You are an OpenShift operator code generation assistant. "
"Execute the oape:api-implement plugin with the provided EP URL. "
),
cwd=working_dir,
permission_mode=CONFIGS.get("permission_mode", "default"),
allowed_tools=CONFIGS['claude_allowed_tools'],
plugins=[{"type": "local", "path": PLUGIN_DIR}],
)
🤖 Prompt for AI Agents
In `@server/server.py` at line 98, The agent is hard-coded with
permission_mode="bypassPermissions" which disables permission checks; change
this to read from a configurable source (e.g., an environment variable like
PERMISSION_MODE or a config setting) and validate allowed values before passing
to the agent initialization (replace the literal "bypassPermissions" used where
permission_mode is set). Default to a restrictive mode (e.g.,
"enforcePermissions") when the env/config is absent, emit a startup warning if a
permissive mode like "bypassPermissions" is selected, and add brief
documentation/comments about the security implications so production deployments
use the safer default.

allowed_tools=CONFIGS['claude_allowed_tools'],
plugins=[{"type": "local", "path": PLUGIN_DIR}],
)

# --- Run the agent ---
output_parts: list[str] = []
conversation: list[dict] = []
cost_usd = 0.0

def _log(role: str, content, **extra):
entry = {"role": role, "content": content, **extra}
conversation.append(entry)
conv_logger.info(f"[{role}] {content}")

conv_logger.info(f"\n{'=' * 60}\n[request] ep_url={ep_url} cwd={working_dir}\n{'=' * 60}")

try:
async for message in query(
prompt=f"/oape:api-implement {ep_url}",
options=options,
):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
output_parts.append(block.text)
_log("assistant", block.text)
else:
_log(f"assistant:{type(block).__name__}",
json.dumps(getattr(block, "__dict__", str(block)), default=str))
elif isinstance(message, ResultMessage):
cost_usd = message.total_cost_usd
if message.result:
output_parts.append(message.result)
_log("result", message.result, cost_usd=cost_usd)
else:
_log(type(message).__name__,
json.dumps(getattr(message, "__dict__", str(message)), default=str))
except Exception as exc:
conv_logger.info(f"[error] {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"Agent execution failed: {exc}")

conv_logger.info(f"[done] cost=${cost_usd:.4f} parts={len(output_parts)}\n")

return {
"status": "success",
"ep_url": ep_url,
"cwd": working_dir,
"output": "\n".join(output_parts),
"cost_usd": cost_usd,
}