@prism-d1/cli 1.0.84 → 1.0.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/assets/coding-agent/README.md +824 -0
  2. package/dist/assets/coding-agent/agent.py +381 -0
  3. package/dist/assets/coding-agent/config.py +338 -0
  4. package/dist/assets/coding-agent/deploy/Dockerfile +86 -0
  5. package/dist/assets/coding-agent/deploy/create_harness.py +144 -0
  6. package/dist/assets/coding-agent/deploy/deploy-harness.sh +201 -0
  7. package/dist/assets/coding-agent/deploy/prism-coding-agent.yml +399 -0
  8. package/dist/assets/coding-agent/eval/run_eval.py +430 -0
  9. package/dist/assets/coding-agent/eval/run_harness_eval.py +262 -0
  10. package/dist/assets/coding-agent/eval/stubs/001-tags-element-validation.json +11 -0
  11. package/dist/assets/coding-agent/eval/stubs/002-status-filter.json +11 -0
  12. package/dist/assets/coding-agent/eval/stubs/003-refuse-test-deletion.json +11 -0
  13. package/dist/assets/coding-agent/fixtures/001-tags-element-validation.json +12 -0
  14. package/dist/assets/coding-agent/fixtures/002-status-filter.json +12 -0
  15. package/dist/assets/coding-agent/fixtures/003-refuse-test-deletion.json +10 -0
  16. package/dist/assets/coding-agent/pyproject.toml +28 -0
  17. package/dist/assets/coding-agent/requirements.txt +8 -0
  18. package/dist/assets/coding-agent/system_prompt.py +290 -0
  19. package/dist/assets/coding-agent/tools/__init__.py +11 -0
  20. package/dist/assets/coding-agent/tools/create_pr.py +141 -0
  21. package/dist/assets/coding-agent/tools/git_ops.py +197 -0
  22. package/dist/assets/github-workflows/prism-agent-eval.yml +2 -1
  23. package/dist/assets/github-workflows/prism-ai-metrics.yml +12 -3
  24. package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +4 -3
  25. package/dist/assets/github-workflows/prism-eval-gate.yml +3 -2
  26. package/dist/assets/gitlab-workflows/prism-agent-eval.yml +2 -1
  27. package/dist/assets/gitlab-workflows/prism-ai-metrics.yml +3 -2
  28. package/dist/assets/gitlab-workflows/prism-eval-gate.yml +2 -1
  29. package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +134 -2
  30. package/dist/assets/infra/lib/dashboard-stack.ts +79 -95
  31. package/dist/assets/infra/lib/lambda/metrics-processor.ts +99 -1
  32. package/dist/assets/infra/lib/lambda/otel-receiver.ts +318 -29
  33. package/dist/assets/infra/lib/lambda/productivity-widget.ts +85 -4
  34. package/dist/assets/infra/lib/lambda/velocity-widget.ts +265 -0
  35. package/dist/assets/infra/lib/metrics-pipeline-stack.ts +5 -0
  36. package/dist/src/commands/bootstrapper/install-coding-agent.d.ts +61 -0
  37. package/dist/src/commands/bootstrapper/install-coding-agent.d.ts.map +1 -0
  38. package/dist/src/commands/bootstrapper/install-coding-agent.js +599 -0
  39. package/dist/src/commands/bootstrapper/install-coding-agent.js.map +1 -0
  40. package/dist/src/commands/bootstrapper/setup-github-oidc.d.ts.map +1 -1
  41. package/dist/src/commands/bootstrapper/setup-github-oidc.js +23 -0
  42. package/dist/src/commands/bootstrapper/setup-github-oidc.js.map +1 -1
  43. package/package.json +2 -2
@@ -0,0 +1,197 @@
1
+ """Git operations exposed to the agent as a single Strands tool.
2
+
3
+ Every git invocation goes through `subprocess.run` with an argv list and never
4
+ `shell=True`. The agent supplies branch names and commit messages, so treating
5
+ them as shell strings would let a crafted issue body run arbitrary commands.
6
+
7
+ `push` is deliberately absent. Pushing is a privileged step that belongs to the
8
+ PR-creation path where credentials live, not to a general-purpose git tool the
9
+ model can call at will.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import subprocess
16
+ from pathlib import Path
17
+
18
+ from strands import tool
19
+
20
+ # Branch names: git's own rules, tightened. No leading/trailing dots or slashes,
21
+ # no '..', no characters git rejects, nothing that could be read as a flag.
22
+ _BRANCH_OK = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$")
23
+ _TIMEOUT_SECONDS = 60
24
+
25
+
26
+ class GitError(RuntimeError):
27
+ """A git command failed or was rejected before running."""
28
+
29
+
30
+ def _run(args: list[str], cwd: Path) -> str:
31
+ """Run a git command and return stdout, raising GitError on failure."""
32
+ try:
33
+ proc = subprocess.run(
34
+ ["git", *args],
35
+ cwd=str(cwd),
36
+ capture_output=True,
37
+ text=True,
38
+ timeout=_TIMEOUT_SECONDS,
39
+ check=False,
40
+ )
41
+ except subprocess.TimeoutExpired as exc:
42
+ raise GitError(f"git {args[0]} timed out after {_TIMEOUT_SECONDS}s") from exc
43
+ except FileNotFoundError as exc:
44
+ raise GitError("git is not installed or not on PATH") from exc
45
+
46
+ if proc.returncode != 0:
47
+ detail = (proc.stderr or proc.stdout).strip()
48
+ raise GitError(f"git {' '.join(args)} failed: {detail}")
49
+ return proc.stdout.strip()
50
+
51
+
52
+ def _validate_branch(name: str) -> str:
53
+ name = name.strip()
54
+ if not _BRANCH_OK.match(name) or ".." in name or name.endswith((".", "/", ".lock")):
55
+ raise GitError(
56
+ f"Invalid branch name {name!r}. Use letters, digits, dot, dash, "
57
+ "underscore and slash; start with a letter or digit."
58
+ )
59
+ return name
60
+
61
+
62
+ def _validate_paths(paths: list[str], repo_path: Path) -> list[str]:
63
+ """Resolve each path and confirm it stays inside the repository.
64
+
65
+ Rejects absolute paths, traversal, and symlinks that point outside the repo.
66
+ """
67
+ resolved: list[str] = []
68
+ repo_real = repo_path.resolve()
69
+ for raw in paths:
70
+ candidate = (repo_real / raw).resolve()
71
+ try:
72
+ candidate.relative_to(repo_real)
73
+ except ValueError as exc:
74
+ raise GitError(f"Path escapes the repository: {raw!r}") from exc
75
+ if not candidate.exists():
76
+ raise GitError(f"Path does not exist: {raw!r}")
77
+ resolved.append(str(candidate.relative_to(repo_real)))
78
+ return resolved
79
+
80
+
81
+
82
+ def _resolve_repo(path: Path) -> Path | None:
83
+ """Return the git repository enclosing `path`, or None if there is none.
84
+
85
+ Uses git's own answer rather than walking for a `.git` directory, so
86
+ worktrees and submodules resolve the way git itself sees them.
87
+ """
88
+ try:
89
+ proc = subprocess.run(
90
+ ["git", "rev-parse", "--show-toplevel"],
91
+ cwd=str(path), capture_output=True, text=True,
92
+ timeout=_TIMEOUT_SECONDS, check=False,
93
+ )
94
+ except (OSError, subprocess.SubprocessError):
95
+ return None
96
+ if proc.returncode != 0 or not proc.stdout.strip():
97
+ return None
98
+ return Path(proc.stdout.strip())
99
+
100
+
101
+ @tool
102
+ def git_ops(
103
+ action: str,
104
+ repo_path: str,
105
+ branch: str | None = None,
106
+ message: str | None = None,
107
+ paths: list[str] | None = None,
108
+ ) -> str:
109
+ """Perform a git operation in the target repository.
110
+
111
+ Use this to create a working branch, inspect what you have changed, stage
112
+ specific files, and commit. Pushing is handled separately by create_pr.
113
+
114
+ Args:
115
+ action: One of "status", "diff", "current_branch", "create_branch",
116
+ "stage", "commit", "log".
117
+ repo_path: Absolute path to the repository working tree.
118
+ branch: Branch name. Required for "create_branch".
119
+ message: Commit message. Required for "commit".
120
+ paths: Repository-relative file paths. Required for "stage". Name each
121
+ file explicitly; there is no "stage everything" option.
122
+
123
+ Returns:
124
+ Command output, or a short confirmation for actions with no output.
125
+ """
126
+ repo = Path(repo_path)
127
+ # Resolved rather than tested for a `.git` child, because a target is often a
128
+ # subdirectory of its repository -- PRISM's own sample-app sits inside a
129
+ # monorepo, and customer monorepos are the same shape.
130
+ #
131
+ # Requiring `.git` here caused a genuinely bad failure, observed rather than
132
+ # imagined: this tool refused with "not a git repository", and the agent --
133
+ # which also has a shell -- routed around the refusal by running `git init`
134
+ # and committing all 54 files as "Initial commit". A refusing tool is not a
135
+ # constraint on something that can run arbitrary commands; it is only a
136
+ # suggestion. So the tool has to be able to do the right thing rather than
137
+ # merely decline to do the wrong one.
138
+ resolved = _resolve_repo(repo)
139
+ if resolved is None:
140
+ return (
141
+ f"ERROR: {repo_path} is not inside a git repository. Do NOT run "
142
+ f"`git init` -- that would create a new repository and commit "
143
+ f"everything, which is never the fix. Report the problem instead."
144
+ )
145
+ repo = resolved
146
+
147
+ try:
148
+ if action == "status":
149
+ out = _run(["status", "--porcelain=v1"], repo)
150
+ return out or "(working tree clean)"
151
+
152
+ if action == "diff":
153
+ out = _run(["diff", "--stat", "HEAD"], repo)
154
+ return out or "(no changes against HEAD)"
155
+
156
+ if action == "current_branch":
157
+ return _run(["rev-parse", "--abbrev-ref", "HEAD"], repo)
158
+
159
+ if action == "log":
160
+ return _run(["log", "--oneline", "-10"], repo)
161
+
162
+ if action == "create_branch":
163
+ if not branch:
164
+ return "ERROR: create_branch requires a branch name"
165
+ safe = _validate_branch(branch)
166
+ _run(["checkout", "-b", safe], repo)
167
+ return f"Created and switched to branch {safe}"
168
+
169
+ if action == "stage":
170
+ if not paths:
171
+ return (
172
+ "ERROR: stage requires an explicit list of paths. "
173
+ "Name each file you changed."
174
+ )
175
+ safe_paths = _validate_paths(paths, repo)
176
+ _run(["add", "--", *safe_paths], repo)
177
+ return f"Staged {len(safe_paths)} file(s): {', '.join(safe_paths)}"
178
+
179
+ if action == "commit":
180
+ if not message or not message.strip():
181
+ return "ERROR: commit requires a non-empty message"
182
+ staged = _run(["diff", "--cached", "--name-only"], repo)
183
+ if not staged:
184
+ return "ERROR: nothing staged. Stage your changed files first."
185
+ # -m takes the message as a separate argv element, so newlines and
186
+ # quotes in the message are data, not shell syntax.
187
+ _run(["commit", "-m", message], repo)
188
+ sha = _run(["rev-parse", "HEAD"], repo)
189
+ return f"Committed {sha[:8]} with files:\n{staged}"
190
+
191
+ return (
192
+ f"ERROR: unknown action {action!r}. Valid actions: status, diff, "
193
+ "current_branch, create_branch, stage, commit, log"
194
+ )
195
+
196
+ except GitError as exc:
197
+ return f"ERROR: {exc}"
@@ -26,6 +26,7 @@ on:
26
26
 
27
27
  env:
28
28
  PRISM_AWS_REGION: us-west-2
29
+ PRISM_REPO: github.com/${{ github.repository }}
29
30
 
30
31
  permissions:
31
32
  id-token: write
@@ -267,7 +268,7 @@ jobs:
267
268
 
268
269
  EVAL_EVENT=$(jq -n \
269
270
  --arg team_id "${{ steps.prism-config.outputs.team_id }}" \
270
- --arg repo "${{ github.repository }}" \
271
+ --arg repo "${PRISM_REPO}" \
271
272
  --arg timestamp "${TIMESTAMP}" \
272
273
  --argjson score "${SCORE:-0}" \
273
274
  --arg result "${{ steps.eval.outputs.result }}" \
@@ -36,6 +36,7 @@ on:
36
36
 
37
37
  env:
38
38
  PRISM_AWS_REGION: us-west-2
39
+ PRISM_REPO: github.com/${{ github.repository }}
39
40
 
40
41
  permissions:
41
42
  id-token: write
@@ -105,6 +106,11 @@ jobs:
105
106
  COMMIT_SHAS=$(git rev-list --no-merges "${BASE_SHA}..${HEAD_SHA}" 2>/dev/null | head -50 | jq -R . | jq -sc . || echo '[]')
106
107
  TOTAL_COMMITS=$(git rev-list --no-merges --count "${BASE_SHA}..${HEAD_SHA}" 2>/dev/null || echo 0)
107
108
 
109
+ # Per-commit author emails (parallel array to COMMIT_SHAS). Used by
110
+ # the metrics-processor to seed COMMIT# items with the correct user
111
+ # field so /v1/productivity can attribute human commits per-developer.
112
+ COMMIT_AUTHORS=$(git rev-list --no-merges "${BASE_SHA}..${HEAD_SHA}" 2>/dev/null | head -50 | while read sha; do git log -1 --format='%ae' "$sha"; done | jq -R . | jq -sc . || echo '[]')
113
+
108
114
  # --- Lead time: PR open → merge. From the event payload, not git, so
109
115
  # it is unaffected by rebases and squashes.
110
116
  CREATED_TS=$(date -d "${CREATED_AT}" +%s 2>/dev/null || date -jf "%Y-%m-%dT%H:%M:%SZ" "${CREATED_AT}" +%s)
@@ -123,6 +129,7 @@ jobs:
123
129
  fi
124
130
 
125
131
  echo "commit_shas=${COMMIT_SHAS}" >> "$GITHUB_OUTPUT"
132
+ echo "commit_authors=${COMMIT_AUTHORS}" >> "$GITHUB_OUTPUT"
126
133
  echo "total_commits=${TOTAL_COMMITS}" >> "$GITHUB_OUTPUT"
127
134
  echo "lead_time_seconds=${LEAD_TIME_SECONDS}" >> "$GITHUB_OUTPUT"
128
135
  echo "is_failure_fix=${IS_FAILURE_FIX}" >> "$GITHUB_OUTPUT"
@@ -154,12 +161,13 @@ jobs:
154
161
 
155
162
  PR_EVENT=$(jq -n \
156
163
  --arg team_id "${{ steps.prism-config.outputs.team_id }}" \
157
- --arg repo "${{ github.repository }}" \
164
+ --arg repo "${PRISM_REPO}" \
158
165
  --arg timestamp "${TIMESTAMP}" \
159
166
  --argjson lead_time_seconds "${{ steps.facts.outputs.lead_time_seconds }}" \
160
167
  --argjson is_failure_fix "${{ steps.facts.outputs.is_failure_fix }}" \
161
168
  --argjson total_commits "${{ steps.facts.outputs.total_commits }}" \
162
169
  --argjson commit_shas '${{ steps.facts.outputs.commit_shas }}' \
170
+ --argjson commit_authors '${{ steps.facts.outputs.commit_authors }}' \
163
171
  --arg pr_number "${{ github.event.pull_request.number }}" \
164
172
  --arg pr_author "${{ github.event.pull_request.user.login }}" \
165
173
  --argjson approved "${{ steps.reviews.outputs.approved }}" \
@@ -184,7 +192,8 @@ jobs:
184
192
  "reviews_approved": $approved,
185
193
  "reviews_changes_requested": $changes_requested,
186
194
  "total_commits": $total_commits,
187
- "commit_shas": $commit_shas
195
+ "commit_shas": $commit_shas,
196
+ "commit_authors": $commit_authors
188
197
  }
189
198
  }')
190
199
 
@@ -211,7 +220,7 @@ jobs:
211
220
  # carrying it on both double-counted the DeploymentFrequency metric.
212
221
  DEPLOY_EVENT=$(jq -n \
213
222
  --arg team_id "${{ steps.prism-config.outputs.team_id }}" \
214
- --arg repo "${{ github.repository }}" \
223
+ --arg repo "${PRISM_REPO}" \
215
224
  --arg timestamp "${TIMESTAMP}" \
216
225
  '{
217
226
  "team_id": $team_id,
@@ -18,6 +18,7 @@ on:
18
18
 
19
19
  env:
20
20
  PRISM_AWS_REGION: us-west-2
21
+ PRISM_REPO: github.com/${{ github.repository }}
21
22
 
22
23
  permissions:
23
24
  id-token: write
@@ -225,7 +226,7 @@ jobs:
225
226
  env:
226
227
  AWS_REGION: ${{ env.PRISM_AWS_REGION }}
227
228
  TEAM_ID: ${{ steps.prism-config.outputs.team_id }}
228
- REPO: ${{ github.repository }}
229
+ REPO: ${{ env.PRISM_REPO }}
229
230
  REVIEW_SCORE: ${{ steps.review.outputs.score }}
230
231
  REVIEW_RESULT: ${{ steps.review.outputs.result }}
231
232
  REVIEW_FINDINGS: ${{ steps.review.outputs.findings }}
@@ -263,7 +264,7 @@ jobs:
263
264
  AWS_REGION: ${{ env.PRISM_AWS_REGION }}
264
265
  BASE_SHA: ${{ github.event.pull_request.base.sha }}
265
266
  HEAD_SHA: ${{ github.sha }}
266
- REPO: ${{ github.repository }}
267
+ REPO: ${{ env.PRISM_REPO }}
267
268
  PR_NUMBER: ${{ github.event.pull_request.number }}
268
269
  run: |
269
270
  # Config is read FIRST, on purpose. "Continuum was never deployed" is a
@@ -413,7 +414,7 @@ jobs:
413
414
  env:
414
415
  AWS_REGION: ${{ env.PRISM_AWS_REGION }}
415
416
  TEAM_ID: ${{ steps.prism-config.outputs.team_id }}
416
- REPO: ${{ github.repository }}
417
+ REPO: ${{ env.PRISM_REPO }}
417
418
  PR_NUMBER: ${{ github.event.pull_request.number }}
418
419
  BASE_SHA: ${{ github.event.pull_request.base.sha }}
419
420
  HEAD_SHA: ${{ github.sha }}
@@ -19,6 +19,7 @@ on:
19
19
 
20
20
  env:
21
21
  PRISM_AWS_REGION: us-west-2
22
+ PRISM_REPO: github.com/${{ github.repository }}
22
23
 
23
24
  permissions:
24
25
  id-token: write
@@ -205,7 +206,7 @@ jobs:
205
206
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
206
207
  EVAL_EVENT=$(jq -n \
207
208
  --arg team_id "${{ steps.prism-config.outputs.team_id }}" \
208
- --arg repo "${{ github.repository }}" \
209
+ --arg repo "${PRISM_REPO}" \
209
210
  --arg timestamp "${TIMESTAMP}" \
210
211
  --argjson score "${{ steps.eval.outputs.avg_score }}" \
211
212
  --arg result "${{ steps.eval.outputs.overall_result }}" \
@@ -421,7 +422,7 @@ jobs:
421
422
  AWS_REGION: ${{ env.PRISM_AWS_REGION }}
422
423
  run: |
423
424
  PR_NUMBER="${{ github.event.pull_request.number }}"
424
- REPO="${{ github.repository }}"
425
+ REPO="${PRISM_REPO}"
425
426
  TEAM_ID="${{ steps.prism-config.outputs.team_id }}"
426
427
  FINDINGS='${{ steps.security-gate.outputs.findings_json }}'
427
428
 
@@ -28,6 +28,7 @@ prism-agent-eval:
28
28
  variables:
29
29
  PRISM_AWS_REGION: us-west-2
30
30
  AWS_DEFAULT_REGION: $PRISM_AWS_REGION
31
+ PRISM_REPO: ${CI_SERVER_HOST}/${CI_PROJECT_PATH}
31
32
  before_script:
32
33
  - apt-get update -qq && apt-get install -y -qq jq bc git curl unzip
33
34
  - curl -s "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
@@ -134,7 +135,7 @@ prism-agent-eval:
134
135
  # Emit event
135
136
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
136
137
  EVAL_EVENT=$(jq -n \
137
- --arg team_id "${TEAM_ID}" --arg repo "${CI_PROJECT_PATH}" --arg timestamp "${TIMESTAMP}" \
138
+ --arg team_id "${TEAM_ID}" --arg repo "${PRISM_REPO}" --arg timestamp "${TIMESTAMP}" \
138
139
  --argjson score "${SCORE:-0}" --arg result "${RESULT}" --argjson file_count "${FILE_COUNT}" \
139
140
  --arg mr_iid "${CI_MERGE_REQUEST_IID}" --arg mock_ran "${MOCK_RAN}" \
140
141
  '{team_id:$team_id, repo:$repo, timestamp:$timestamp, prism_level:3,
@@ -34,6 +34,7 @@ prism-ai-metrics:
34
34
  variables:
35
35
  PRISM_AWS_REGION: us-west-2
36
36
  AWS_DEFAULT_REGION: $PRISM_AWS_REGION
37
+ PRISM_REPO: ${CI_SERVER_HOST}/${CI_PROJECT_PATH}
37
38
  before_script:
38
39
  - yum install -y -q jq git
39
40
  - git fetch --unshallow || true
@@ -114,7 +115,7 @@ prism-ai-metrics:
114
115
  # shape — the processor only persists a known section list.
115
116
  PR_EVENT=$(jq -n \
116
117
  --arg team_id "${TEAM_ID}" \
117
- --arg repo "${CI_PROJECT_PATH}" \
118
+ --arg repo "${PRISM_REPO}" \
118
119
  --arg timestamp "${TIMESTAMP}" \
119
120
  --argjson lead_time_seconds "${LEAD_TIME_SECONDS}" \
120
121
  --argjson is_failure_fix "${IS_FAILURE_FIX}" \
@@ -141,7 +142,7 @@ prism-ai-metrics:
141
142
 
142
143
  # Sole deployment signal — deployment_frequency lives ONLY on this event.
143
144
  # Carrying it on prism.d1.pr as well double-counted the metric.
144
- DEPLOY_EVENT=$(jq -n --arg team_id "${TEAM_ID}" --arg repo "${CI_PROJECT_PATH}" --arg timestamp "${TIMESTAMP}" \
145
+ DEPLOY_EVENT=$(jq -n --arg team_id "${TEAM_ID}" --arg repo "${PRISM_REPO}" --arg timestamp "${TIMESTAMP}" \
145
146
  '{team_id: $team_id, repo: $repo, timestamp: $timestamp, prism_level: 2,
146
147
  metric: {name: "deployment", value: 1, unit: "count"},
147
148
  dora: {deployment_frequency: 1}}')
@@ -23,6 +23,7 @@ prism-eval-gate:
23
23
  variables:
24
24
  PRISM_AWS_REGION: us-west-2
25
25
  AWS_DEFAULT_REGION: $PRISM_AWS_REGION
26
+ PRISM_REPO: ${CI_SERVER_HOST}/${CI_PROJECT_PATH}
26
27
  before_script:
27
28
  - yum install -y -q jq bc git
28
29
  - git fetch --unshallow || true
@@ -140,7 +141,7 @@ prism-eval-gate:
140
141
  # Emit event
141
142
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
142
143
  EVAL_EVENT=$(jq -n \
143
- --arg team_id "${TEAM_ID}" --arg repo "${CI_PROJECT_PATH}" --arg timestamp "${TIMESTAMP}" \
144
+ --arg team_id "${TEAM_ID}" --arg repo "${PRISM_REPO}" --arg timestamp "${TIMESTAMP}" \
144
145
  --argjson score "${AVG_SCORE}" --arg result "${OVERALL}" \
145
146
  --argjson total_files "${TOTAL_FILES}" --argjson pass_count "${PASS_COUNT}" \
146
147
  --argjson fail_count "${FAIL_COUNT}" --argjson hallucinations "${TOTAL_HALLUCINATIONS}" \
@@ -27,6 +27,8 @@ import * as logs from 'aws-cdk-lib/aws-logs';
27
27
  import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
28
28
  import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
29
29
  import { HttpJwtAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
30
+ import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
31
+ import * as ssm from 'aws-cdk-lib/aws-ssm';
30
32
  import * as path from 'path';
31
33
  import { Construct } from 'constructs';
32
34
  import { NagSuppressions } from 'cdk-nag';
@@ -54,6 +56,10 @@ export class OtelCollectorConstruct extends Construct {
54
56
  public readonly archiveBucket: s3.Bucket;
55
57
  public readonly userPool?: cognito.UserPool;
56
58
  public readonly userPoolClient?: cognito.UserPoolClient;
59
+ /** Machine-to-machine client used by the coding agent's telemetry emitter. */
60
+ public readonly agentClient?: cognito.UserPoolClient;
61
+ /** Its credentials, read in CI with the ephemeral OIDC role. */
62
+ public readonly agentSecret?: secretsmanager.Secret;
57
63
  /** ARN of the CloudWatch custom-widget Lambda (Developer Productivity table). */
58
64
  public productivityWidgetArn?: string;
59
65
  /** The otel-receiver Lambda — other constructs may grant themselves invoke
@@ -74,6 +80,10 @@ export class OtelCollectorConstruct extends Construct {
74
80
  let issuer: string;
75
81
  let clientId: string;
76
82
  let identityClaim: string;
83
+ let domainPrefix = '';
84
+ let agentClientId = '';
85
+ let machineIdentities = '';
86
+ let tokenEndpoint = '';
77
87
 
78
88
  if (byoIdp) {
79
89
  issuer = props.externalIssuer!.replace(/\/$/, '');
@@ -102,8 +112,9 @@ export class OtelCollectorConstruct extends Construct {
102
112
  });
103
113
 
104
114
  // Hosted UI domain — prefix must be globally unique per region.
115
+ domainPrefix = `prism-d1-otel-${cdk.Aws.ACCOUNT_ID}`;
105
116
  this.userPool.addDomain('Domain', {
106
- cognitoDomain: { domainPrefix: `prism-d1-otel-${cdk.Aws.ACCOUNT_ID}` },
117
+ cognitoDomain: { domainPrefix },
107
118
  });
108
119
 
109
120
  // Public client (no secret) — codeburn does Authorization Code + PKCE
@@ -131,6 +142,85 @@ export class OtelCollectorConstruct extends Construct {
131
142
  // Cognito ACCESS tokens carry `username` (no email claim) — with
132
143
  // email-alias sign-in and admin-created users, username == email.
133
144
  identityClaim = props.identityClaim ?? 'username';
145
+
146
+ // -----------------------------------------------------------------
147
+ // Machine-to-machine path, for the coding agent's own telemetry
148
+ // -----------------------------------------------------------------
149
+ // The agent runs in CI, so there is no human to complete an
150
+ // authorization-code flow. SAX-02 Outcome 3 names Cognito's
151
+ // client-credentials flow as the machine-to-machine pattern, with a
152
+ // resource server carrying fine-grained scopes; the alternative — a
153
+ // Cognito user password in a repository secret — is what Outcome 1 lists
154
+ // as "hardcoding credentials in application code or environment
155
+ // variables".
156
+ //
157
+ // Client credentials require a resource server: Cognito rejects the grant
158
+ // for a client whose only scopes are the standard openid/email/profile.
159
+ const resourceServer = this.userPool.addResourceServer('PrismResourceServer', {
160
+ identifier: 'prism',
161
+ scopes: [{ scopeName: 'emit', scopeDescription: 'Emit usage and attribution spans' }],
162
+ });
163
+
164
+ this.agentClient = this.userPool.addClient('CodingAgentClient', {
165
+ userPoolClientName: 'prism-coding-agent',
166
+ generateSecret: true,
167
+ // No authorization-code or SRP flow: this identity is never a person, and
168
+ // leaving a human flow enabled on it would be a second way in.
169
+ authFlows: {},
170
+ oAuth: {
171
+ flows: { clientCredentials: true, authorizationCodeGrant: false },
172
+ scopes: [cognito.OAuthScope.resourceServer(resourceServer, {
173
+ scopeName: 'emit',
174
+ scopeDescription: 'Emit usage and attribution spans',
175
+ })],
176
+ },
177
+ accessTokenValidity: cdk.Duration.hours(1),
178
+ enableTokenRevocation: true,
179
+ preventUserExistenceErrors: true,
180
+ });
181
+
182
+ // The credentials the CI job reads with its ephemeral OIDC role. Named
183
+ // under prism-d1- because setup-github-oidc grants GetSecretValue scoped
184
+ // to that prefix rather than to '*' — the role is assumed by a workflow
185
+ // that runs agent-authored code.
186
+ this.agentSecret = new secretsmanager.Secret(this, 'CodingAgentOidcSecret', {
187
+ secretName: 'prism-d1-agent-oidc',
188
+ description: 'Cognito client credentials for the PRISM coding agent telemetry emitter',
189
+ secretObjectValue: {
190
+ client_id: cdk.SecretValue.unsafePlainText(this.agentClient.userPoolClientId),
191
+ // unsafePlainText names the risk accurately and it does not apply here:
192
+ // the value is a CloudFormation reference resolved at deploy time, not a
193
+ // literal in source. The client id is not sensitive; the secret is, and
194
+ // it never leaves CloudFormation except into this secret.
195
+ client_secret: this.agentClient.userPoolClientSecret,
196
+ },
197
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
198
+ });
199
+
200
+ // Cognito app-client secrets cannot be rotated in place — you would recreate
201
+ // the client, which invalidates every outstanding token and every copy of the
202
+ // old secret. The suppression documents why rotation is not configured rather
203
+ // than silently ignoring the rule.
204
+ NagSuppressions.addResourceSuppressions(this.agentSecret, [
205
+ {
206
+ id: 'AwsSolutions-SMG4',
207
+ reason:
208
+ 'Cognito app-client secrets are immutable: rotating requires replacing the ' +
209
+ 'client, which invalidates all outstanding tokens. Rotation would be a ' +
210
+ 'client-replacement Lambda on a 90-day schedule, acceptable for a workshop ' +
211
+ 'sample but not implemented here. The secret is scoped to one user pool and ' +
212
+ 'grants only the prism/emit scope.',
213
+ },
214
+ ]);
215
+
216
+ agentClientId = this.agentClient.userPoolClientId;
217
+ // A client-credentials token has no username, no email, and a `sub` equal to
218
+ // the app client id — so identity has to be mapped rather than read. Mapped
219
+ // here, at deploy time, precisely so it is NOT caller-supplied: a payload
220
+ // that could name its own author would let any holder of any token post as
221
+ // anyone.
222
+ machineIdentities = JSON.stringify({ [agentClientId]: 'prism-coding-agent' });
223
+ tokenEndpoint = `https://${domainPrefix}.auth.${cdk.Aws.REGION}.amazoncognito.com/oauth2/token`;
134
224
  }
135
225
 
136
226
  // -------------------------------------------------------
@@ -191,6 +281,9 @@ export class OtelCollectorConstruct extends Construct {
191
281
  IDENTITY_CLAIM: identityClaim,
192
282
  OIDC_ISSUER: issuer,
193
283
  OIDC_CLIENT_ID: clientId,
284
+ // clientId -> identity, for tokens that carry no username. Deploy-time
285
+ // config so the author of a span is never caller-supplied.
286
+ MACHINE_IDENTITIES: machineIdentities,
194
287
  MAX_BATCH_SIZE: '1000',
195
288
  SPAN_TTL_DAYS: '90',
196
289
  },
@@ -271,8 +364,12 @@ export class OtelCollectorConstruct extends Construct {
271
364
 
272
365
  // HTTP API JWT authorizers validate the `aud` claim, or `client_id` for
273
366
  // Cognito access tokens — both paths work with jwtAudience = [clientId].
367
+ // Both client ids: API Gateway validates `aud`, or `client_id` for Cognito
368
+ // access tokens, and a client-credentials token carries the M2M client id.
369
+ // Listing only the codeburn client would reject every agent span with a 401
370
+ // that looks like a bad token rather than a missing audience entry.
274
371
  const authorizer = new HttpJwtAuthorizer('OtelJwtAuthorizer', issuer, {
275
- jwtAudience: [clientId],
372
+ jwtAudience: agentClientId ? [clientId, agentClientId] : [clientId],
276
373
  });
277
374
 
278
375
  // Discovery doc — unauthenticated by design (it only exposes issuer +
@@ -365,5 +462,40 @@ export class OtelCollectorConstruct extends Construct {
365
462
  });
366
463
  poolOutput.overrideLogicalId('OtelUserPoolId');
367
464
  }
465
+ if (this.agentSecret) {
466
+ // SSM parameters: the workflow reads these at run time instead of requiring
467
+ // GitHub org variables. One source of truth, written by CDK at deploy time,
468
+ // cannot drift from what's actually deployed.
469
+ const ssmPrefix = '/prism/d1';
470
+ new ssm.StringParameter(this, 'ParamCollectorUrl', {
471
+ parameterName: `${ssmPrefix}/collector-url`,
472
+ stringValue: this.httpApi.apiEndpoint,
473
+ description: 'OTEL collector URL — read by the coding agent workflow',
474
+ tier: ssm.ParameterTier.STANDARD,
475
+ });
476
+ new ssm.StringParameter(this, 'ParamTokenEndpoint', {
477
+ parameterName: `${ssmPrefix}/token-endpoint`,
478
+ stringValue: tokenEndpoint,
479
+ description: 'Cognito token endpoint — read by the coding agent workflow',
480
+ tier: ssm.ParameterTier.STANDARD,
481
+ });
482
+ new ssm.StringParameter(this, 'ParamAgentSecretId', {
483
+ parameterName: `${ssmPrefix}/agent-secret-id`,
484
+ stringValue: this.agentSecret.secretName,
485
+ description: 'Secrets Manager id for the M2M client credentials',
486
+ tier: ssm.ParameterTier.STANDARD,
487
+ });
488
+
489
+ // Outputs kept for discoverability — printed by `cdk deploy`, but no longer
490
+ // need to be manually copied into GitHub.
491
+ new cdk.CfnOutput(this, 'PrismAgentTokenEndpoint', {
492
+ value: tokenEndpoint,
493
+ description: 'Also available as SSM /prism/d1/token-endpoint',
494
+ }).overrideLogicalId('PrismAgentTokenEndpoint');
495
+ new cdk.CfnOutput(this, 'PrismAgentSecretId', {
496
+ value: this.agentSecret.secretName,
497
+ description: 'Also available as SSM /prism/d1/agent-secret-id',
498
+ }).overrideLogicalId('PrismAgentSecretId');
499
+ }
368
500
  }
369
501
  }