-
Notifications
You must be signed in to change notification settings - Fork 6
Integrate with Claude Agent SDK #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d064d6f
d67e148
0973770
4d31efa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }} |
| 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"] |
| 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 | ||
| --- | ||
| apiVersion: v1 | ||
| kind: Service | ||
| metadata: | ||
| name: oape-server | ||
| spec: | ||
| selector: | ||
| app: oape-server | ||
| ports: | ||
| - port: 8000 | ||
| targetPort: 8000 | ||
| type: ClusterIP | ||
| 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" | ||
| ] | ||
| } |
| 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 |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct the module path in the usage snippet. Line 6 points to 🛠️ Proposed fix- uvicorn api.server:app --reload
+ uvicorn server.server:app --reload🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Config file path is relative to CWD, not the module location. If the server is started from a directory other than 🐛 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 |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove debug print 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 |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid a fixed log file in /tmp. Line 52 uses a hard-coded 🔒 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
Suggested change
🧰 Tools🪛 Ruff (0.15.0)[error] 52-52: Probable insecure usage of temporary file or directory: "/tmp/conversation.log" (S108) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @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", | ||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Document or make Setting
🔒 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a securityContext and a writable /tmp mount.
The deployment lacks a hardened securityContext. If you enable a read-only root filesystem,
/tmpwill need anemptyDirfor 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