Skip to content
Closed
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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -1057,8 +1057,8 @@ validate-registry:
# raw OTLP JSONL mirrors, and shipped GenAI compatibility attributes.
.PHONY: validate-otel-contract
validate-otel-contract:
@echo "Validating gh-aw OpenTelemetry compatibility contract..."
@go test ./pkg/parser ./pkg/workflow -run 'TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLP(CustomAttributes|ResourceAttributes|GitHubAppImplicitOIDC)|TestInjectOTLPConfig|TestApplyTraceContextEnvToMap' -count=1
@echo "Validating gh-aw OpenTelemetry compatibility contract (T-OT-001 through T-OT-011)..."
@go test ./pkg/parser ./pkg/workflow -run 'TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLP(CustomAttributes|ResourceAttributes|GitHubAppImplicitOIDC)|TestInjectOTLPConfig|TestApplyTraceContextEnvToMap|TestFormal_OTelComplianceRuntimeContractSuiteIncludesLevel1IDs' -count=1
@cd actions/setup/js && npm run test:js -- otel_contract.test.cjs send_otlp_span.test.cjs --no-file-parallelism >/dev/null
@echo "✓ OpenTelemetry compatibility contract validated"

Expand Down
4 changes: 2 additions & 2 deletions actions/setup/js/emit_outcome_spans.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ require("./shim.cjs");
* for per-workflow, per-type, and per-result drill-down.
*
* Span naming:
* - Per-item: gh-aw.outcome.evaluation
* - Per-item: gh-aw.outcome.evaluate
* - Summary: gh-aw.outcome.summary
*
* Errors are non-fatal: export failures must never break the workflow.
Expand Down Expand Up @@ -201,7 +201,7 @@ async function main() {
traceId,
spanId: generateSpanId(),
parentSpanId: summarySpanId,
spanName: "gh-aw.outcome.evaluation",
spanName: "gh-aw.outcome.evaluate",
startMs: evalEndMs - 1, // point-in-time span
endMs: evalEndMs,
attributes,
Expand Down
4 changes: 2 additions & 2 deletions actions/setup/js/emit_outcome_spans.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -285,14 +285,14 @@ describe("emit_outcome_spans.cjs", () => {
);
expect(spans[1]).toEqual(
expect.objectContaining({
spanName: "gh-aw.outcome.evaluation",
spanName: "gh-aw.outcome.evaluate",
parentSpanId: summarySpan.spanId,
statusCode: 1,
})
);
expect(spans[2]).toEqual(
expect.objectContaining({
spanName: "gh-aw.outcome.evaluation",
spanName: "gh-aw.outcome.evaluate",
parentSpanId: summarySpan.spanId,
statusCode: 0,
})
Expand Down
96 changes: 96 additions & 0 deletions actions/setup/js/otel_contract.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "fs";

const { sendJobSetupSpan, sendJobConclusionSpan, OTEL_JSONL_PATH } = await import("./send_otlp_span.cjs");
const { main: emitOutcomeSpans } = await import("./emit_outcome_spans.cjs");

const MANAGED_ENV_VARS = [
"GH_AW_OTLP_ENDPOINTS",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"INPUT_JOB_NAME",
"INPUT_TRACE_ID",
"INPUT_PARENT_SPAN_ID",
Expand Down Expand Up @@ -41,6 +43,10 @@ function firstSpan(payload) {
return payload.resourceSpans[0].scopeSpans[0].spans[0];
}

function allSpans(payload) {
return payload.resourceSpans.flatMap(rs => rs.scopeSpans.flatMap(ss => ss.spans));
}

describe("gh-aw OpenTelemetry compatibility contract", () => {
let appendFileSyncSpy;
let mkdirSyncSpy;
Expand All @@ -55,6 +61,7 @@ describe("gh-aw OpenTelemetry compatibility contract", () => {
}

process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://traces.example.com" }]);
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://traces.example.com";
process.env.OTEL_SERVICE_NAME = "gh-aw.customer-contract";
process.env.GH_AW_INFO_WORKFLOW_NAME = "Customer OTEL Contract";
process.env.GITHUB_RUN_ID = "1234567890";
Expand Down Expand Up @@ -83,6 +90,21 @@ describe("gh-aw OpenTelemetry compatibility contract", () => {
if (filePath === "/tmp/gh-aw/agent_output.json") {
return JSON.stringify({ items: [], errors: [] });
}
if (filePath === "/tmp/gh-aw/outcome-evaluations.jsonl") {
return (
JSON.stringify({
type: "replace_label",
result: "accepted",
outcome_status: "accepted",
workflow: "replace-label",
run_id: 1234567890,
repo: "github/gh-aw",
}) + "\n"
);
}
if (filePath === "/tmp/gh-aw/outcome-summary.json") {
return JSON.stringify({ total_outcomes: 1, accepted: 1, rejected: 0, pending: 0, ignored: 0 });
}
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});

Expand Down Expand Up @@ -154,4 +176,78 @@ describe("gh-aw OpenTelemetry compatibility contract", () => {
expect(payload).not.toHaveProperty("payload");
}
});

it("T-OT-008: emits gh-aw.job.name on built-in setup spans", async () => {
process.env.INPUT_JOB_NAME = "agent";

await sendJobSetupSpan({
traceId: "a".repeat(32),
parentSpanId: "b".repeat(16),
startMs: 1_700_000_000_000,
});

const setupSpan = firstSpan(JSON.parse(String(appendFileSyncSpy.mock.calls[0][1]).trim()));
expect(setupSpan.name).toBe("gh-aw.agent.setup");
expect(attrsByKey(setupSpan)["gh-aw.job.name"]).toBe("agent");
});

it("T-OT-009: emits normalized gen_ai.system on built-in agent spans", async () => {
process.env.INPUT_JOB_NAME = "agent";

const setup = await sendJobSetupSpan({
traceId: "a".repeat(32),
parentSpanId: "b".repeat(16),
startMs: 1_700_000_000_000,
});
process.env.GITHUB_AW_OTEL_TRACE_ID = setup.traceId;
process.env.GITHUB_AW_OTEL_PARENT_SPAN_ID = setup.spanId;

await sendJobConclusionSpan("gh-aw.agent.conclusion", { startMs: 1_700_000_001_000 });

const spans = appendFileSyncSpy.mock.calls.flatMap(([, line]) => allSpans(JSON.parse(String(line).trim())));
const agentSpan = spans.find(span => span.name === "gh-aw.agent.agent");
expect(agentSpan).toBeTruthy();
expect(attrsByKey(agentSpan)["gen_ai.system"]).toBe("anthropic");
});

it("T-OT-010: emits gh-aw.outcome.type on outcome-evaluation spans", async () => {
await emitOutcomeSpans();

const payload = JSON.parse(String(appendFileSyncSpy.mock.calls[0][1]).trim());
const outcomeSpan = allSpans(payload).find(span => span.name === "gh-aw.outcome.evaluate");
expect(outcomeSpan).toBeTruthy();
const attrs = attrsByKey(outcomeSpan);
expect(attrs["gh-aw.outcome.type"]).toBe("replace_label");
expect(attrs["gh-aw.outcome.result"]).toBe("accepted");
});

it("T-OT-011: preserves v0.3.0 built-in span names and attribute inventory", async () => {
process.env.INPUT_JOB_NAME = "agent";

const setup = await sendJobSetupSpan({
traceId: "a".repeat(32),
parentSpanId: "b".repeat(16),
startMs: 1_700_000_000_000,
});
process.env.GITHUB_AW_OTEL_TRACE_ID = setup.traceId;
process.env.GITHUB_AW_OTEL_PARENT_SPAN_ID = setup.spanId;
await sendJobConclusionSpan("gh-aw.agent.conclusion", { startMs: 1_700_000_001_000 });

const spans = appendFileSyncSpy.mock.calls.flatMap(([, line]) => allSpans(JSON.parse(String(line).trim())));
expect(spans.map(span => span.name)).toEqual(["gh-aw.agent.setup", "gh-aw.agent.agent", "gh-aw.agent.conclusion"]);

const setupAttrs = attrsByKey(spans[0]);
for (const key of ["gh-aw.workflow.name", "gh-aw.job.name", "gh-aw.run.id", "gh-aw.repository", "gh-aw.engine.id", "gen_ai.system"]) {
expect(setupAttrs).toHaveProperty(key);
}

const resourceAttrs = attrsByKey({ attributes: appendFileSyncSpy.mock.calls.map(([, line]) => JSON.parse(String(line).trim()))[0].resourceSpans[0].resource.attributes });
for (const key of ["github.repository", "github.run_id", "github.run_attempt", "github.event_name", "github.job"]) {
expect(resourceAttrs).toHaveProperty(key);
}

const agentAttrs = attrsByKey(spans[1]);
expect(agentAttrs).toHaveProperty("gen_ai.usage.total_tokens");
expect(fetchMock.mock.calls.map(([url]) => url)).toContain("https://traces.example.com/v1/traces");
});
});
170 changes: 170 additions & 0 deletions pkg/cli/mcp_intent_authorization.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package cli

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"

"github.com/github/gh-aw/pkg/intent"
"github.com/github/gh-aw/pkg/intent/authz"
"github.com/github/gh-aw/pkg/logger"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

const (
intentPolicyEnforcementEnv = "GH_AW_INTENT_POLICY_ENFORCEMENT"
intentPolicyPathEnv = "GH_AW_INTENT_POLICY_PATH"
intentLabelsEnv = "GH_AW_INTENT_LABELS"
intentHumanApprovedEnv = "GH_AW_INTENT_HUMAN_APPROVED"
intentPassedChecksEnv = "GH_AW_INTENT_REQUIRED_CHECKS_PASSED"
intentAttemptEnv = "GH_AW_INTENT_ATTEMPT"
intentDefaultBranchEnv = "GH_AW_INTENT_DEFAULT_BRANCH"
)

var mcpIntentAuthzLog = logger.New("mcp:intent_authorization")

type intentPolicyFile struct {
Rules []intent.PolicyRule `json:"rules"`
}

func intentPolicyEnforcementEnabled() bool {
return os.Getenv(intentPolicyEnforcementEnv) == "true"
}

func intentAuthorizationMiddleware() mcp.Middleware {
compiler, err := loadIntentPolicyCompiler()
if err != nil {
mcpIntentAuthzLog.Printf("intent policy enforcement failed closed: %v", err)
return failedClosedIntentAuthorizationMiddleware(err)
}
authorizer := authz.Authorizer{}
return intentAuthorizationMiddlewareForPolicy(compiler, authorizer.AuthorizeTool)
}

func failedClosedIntentAuthorizationMiddleware(loadErr error) mcp.Middleware {
return func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
if method != "tools/call" {
return next(ctx, method, req)
}
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("intent policy enforcement failed closed: %v", loadErr)}},
}, nil
}
}
}

func intentAuthorizationMiddlewareForPolicy(compiler intent.PolicyCompiler, authorize func(intent.ExecutionPolicy, string, authz.ToolContext) error) mcp.Middleware {
return func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
if method != "tools/call" {
return next(ctx, method, req)
}
toolName := extractMCPToolName(req)
policy := compiler.Compile(intent.IntentRecord{
Status: intent.AttributionMapped,
Labels: splitCSVEnv(intentLabelsEnv),
}, currentRepositoryContext())
if err := authorize(policy, toolName, toolContextForMCPTool(toolName)); err != nil {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}},
}, nil
}
return next(ctx, method, req)
}
}
}

func loadIntentPolicyCompiler() (intent.PolicyCompiler, error) {
path := os.Getenv(intentPolicyPathEnv)
if path == "" {
path = filepath.Join(".github", "intent-policy.json")
}
data, err := os.ReadFile(path)
if err != nil {
return intent.PolicyCompiler{}, err
}
var cfg intentPolicyFile
if err := json.Unmarshal(data, &cfg); err != nil {
return intent.PolicyCompiler{}, err
}
if len(cfg.Rules) == 0 {
return intent.PolicyCompiler{}, errors.New("intent policy has no rules")
}
return intent.PolicyCompiler{Rules: cfg.Rules}, nil
}

func currentRepositoryContext() intent.RepositoryContext {
repo := os.Getenv("GITHUB_REPOSITORY")
owner, name, _ := strings.Cut(repo, "/")
return intent.RepositoryContext{Owner: owner, Org: owner, Name: name}
}

func toolContextForMCPTool(toolName string) authz.ToolContext {
return authz.ToolContext{
IsWrite: isIntentWriteTool(toolName),
IsAutoMerge: toolName == "merge_pull_request",
Branch: currentBranch(),
DefaultBranch: os.Getenv(intentDefaultBranchEnv),
Approved: os.Getenv(intentHumanApprovedEnv) == "true",
PassedChecks: splitCSVEnv(intentPassedChecksEnv),
Attempt: intentAttempt(),
}
}

func isIntentWriteTool(toolName string) bool {
switch toolName {
case "add", "update", "fix", "merge_pull_request", "create_or_update_file", "push_files", "delete_file":
return true
default:
return false
}
}

func intentAttempt() int {
raw := os.Getenv(intentAttemptEnv)
if raw == "" {
return 1
}
attempt, err := strconv.Atoi(raw)
if err != nil || attempt < 1 {
return 1
}
return attempt
}

func splitCSVEnv(name string) []string {
raw := os.Getenv(name)
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
if value := strings.TrimSpace(part); value != "" {
values = append(values, value)
}
}
return values
}

func currentBranch() string {
for _, name := range []string{"GITHUB_HEAD_REF", "GITHUB_REF_NAME"} {
if value := os.Getenv(name); value != "" {
return value
}
}
out, err := exec.Command("git", "branch", "--show-current").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
Loading