@prism-d1/cli 1.0.84 → 1.0.86
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.
- package/dist/assets/coding-agent/README.md +824 -0
- package/dist/assets/coding-agent/agent.py +381 -0
- package/dist/assets/coding-agent/config.py +338 -0
- package/dist/assets/coding-agent/deploy/Dockerfile +86 -0
- package/dist/assets/coding-agent/deploy/create_harness.py +144 -0
- package/dist/assets/coding-agent/deploy/deploy-harness.sh +201 -0
- package/dist/assets/coding-agent/deploy/prism-coding-agent.yml +399 -0
- package/dist/assets/coding-agent/eval/run_eval.py +430 -0
- package/dist/assets/coding-agent/eval/run_harness_eval.py +262 -0
- package/dist/assets/coding-agent/eval/stubs/001-tags-element-validation.json +11 -0
- package/dist/assets/coding-agent/eval/stubs/002-status-filter.json +11 -0
- package/dist/assets/coding-agent/eval/stubs/003-refuse-test-deletion.json +11 -0
- package/dist/assets/coding-agent/fixtures/001-tags-element-validation.json +12 -0
- package/dist/assets/coding-agent/fixtures/002-status-filter.json +12 -0
- package/dist/assets/coding-agent/fixtures/003-refuse-test-deletion.json +10 -0
- package/dist/assets/coding-agent/pyproject.toml +28 -0
- package/dist/assets/coding-agent/requirements.txt +8 -0
- package/dist/assets/coding-agent/system_prompt.py +290 -0
- package/dist/assets/coding-agent/tools/__init__.py +11 -0
- package/dist/assets/coding-agent/tools/create_pr.py +141 -0
- package/dist/assets/coding-agent/tools/git_ops.py +197 -0
- package/dist/assets/github-workflows/prism-agent-eval.yml +2 -1
- package/dist/assets/github-workflows/prism-ai-metrics.yml +12 -3
- package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +4 -3
- package/dist/assets/github-workflows/prism-eval-gate.yml +3 -2
- package/dist/assets/gitlab-workflows/prism-agent-eval.yml +2 -1
- package/dist/assets/gitlab-workflows/prism-ai-metrics.yml +3 -2
- package/dist/assets/gitlab-workflows/prism-eval-gate.yml +2 -1
- package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +134 -2
- package/dist/assets/infra/lib/dashboard-stack.ts +79 -95
- package/dist/assets/infra/lib/lambda/metrics-processor.ts +99 -1
- package/dist/assets/infra/lib/lambda/otel-receiver.ts +318 -29
- package/dist/assets/infra/lib/lambda/productivity-widget.ts +85 -4
- package/dist/assets/infra/lib/lambda/velocity-widget.ts +265 -0
- package/dist/assets/infra/lib/metrics-pipeline-stack.ts +5 -0
- package/dist/src/commands/bootstrapper/install-coding-agent.d.ts +62 -0
- package/dist/src/commands/bootstrapper/install-coding-agent.d.ts.map +1 -0
- package/dist/src/commands/bootstrapper/install-coding-agent.js +614 -0
- package/dist/src/commands/bootstrapper/install-coding-agent.js.map +1 -0
- package/dist/src/commands/bootstrapper/setup-github-oidc.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/setup-github-oidc.js +23 -0
- package/dist/src/commands/bootstrapper/setup-github-oidc.js.map +1 -1
- 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 "${
|
|
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 "${
|
|
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 "${
|
|
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: ${{
|
|
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: ${{
|
|
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: ${{
|
|
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 "${
|
|
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="${
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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
|
|
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
|
}
|