@showbegin/pi-aws-accounts 0.1.0
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/LICENSE +21 -0
- package/README.md +73 -0
- package/aws-accounts.example.json +7 -0
- package/package.json +20 -0
- package/src/index.ts +307 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 showbegin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# @showbegin/pi-aws-accounts
|
|
2
|
+
|
|
3
|
+
A [Pi](https://pi.dev) extension that gives the agent **one safe, visible control point for multi-account AWS + EKS work**:
|
|
4
|
+
|
|
5
|
+
- `/aws-switch <env>` — switch the active AWS profile **and** kube context together, live, mid-session. No restart, no `--profile` on every command.
|
|
6
|
+
- A persistent **status indicator** showing the active env / profile / context — so you (and the model) always know which account is live.
|
|
7
|
+
- A **prod guardrail** — when a prod env is active, mutating `aws` / `kubectl` / `helm` commands require confirmation before the agent can run them. It re-checks the *live* context on every command, so a switch made even in a separate terminal can't slip past.
|
|
8
|
+
|
|
9
|
+
## Why this exists
|
|
10
|
+
|
|
11
|
+
Switching AWS context in a *shell* is a solved problem — [granted](https://github.com/common-fate/granted), [aws-vault](https://github.com/99designs/aws-vault), [kubectx](https://github.com/ahmetb/kubectx), [direnv](https://direnv.net/). But none of them reach **inside an agent harness**: they switch your shell, not the context the agent's `bash` commands run in, and none gate the agent's actions on prod. The moment you put an LLM with shell access in front of multiple AWS accounts, the harness needs its own context control and its own guardrail. That's the gap this fills.
|
|
12
|
+
|
|
13
|
+
The switch itself is fully deterministic — the LLM is never in the path that decides which account is active. The model does the operational work *within* a context you set; it cannot fat-finger the wrong account.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:@showbegin/pi-aws-accounts
|
|
19
|
+
# or, local checkout:
|
|
20
|
+
pi install ./pi-aws-accounts
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That's it — there's **no config to write**. On startup the extension auto-derives your switchable envs from `~/.aws/config` (your profiles) and your kube contexts, pairing each profile to its cluster by AWS account id. Run `/aws-switch` to see what it found.
|
|
24
|
+
|
|
25
|
+
## Configuration (optional)
|
|
26
|
+
|
|
27
|
+
You only need a config file to **arm the prod guard** or to cover envs the auto-derive can't see. The easiest way to arm the guard is to run **`/aws-set-prod`** inside Pi — it writes the prod flag for the cluster you pick, no hand-editing. Or edit it directly; it lives at `~/.pi/agent/aws-accounts.json` (local to your machine — account ids never go in this package); copy [`aws-accounts.example.json`](./aws-accounts.example.json):
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"prodAccounts": ["111111111111"],
|
|
32
|
+
"envs": {
|
|
33
|
+
"stage": { "context": "arn:aws:eks:eu-central-1:222222222222:cluster/stage" }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- **`prodAccounts`** — AWS account ids that are production. Any env (derived or explicit) in one of these accounts gets the prod guard. Marking prod by **account**, not by cluster name, is deliberate: the account is the real blast-radius boundary, and it never lies the way a name like `prod-test` does.
|
|
39
|
+
- **`envs`** — only for what auto-derive misses: a kube context with no matching AWS profile, a custom (non-ARN) context, a friendlier alias, or a manual pairing. Anything here is unioned with, and overrides, the derived envs.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
| Command | Action |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `/aws-switch` | Open an interactive picker of detected clusters |
|
|
46
|
+
| `/aws-switch stage` | Switch directly to a cluster by name |
|
|
47
|
+
| `/aws-set-prod` | Pick a cluster to mark as **production** (persists, arms the guard) |
|
|
48
|
+
| `/aws-whoami` | Print active profile, `sts get-caller-identity`, and kube context |
|
|
49
|
+
|
|
50
|
+
## Prerequisites & graceful degradation
|
|
51
|
+
|
|
52
|
+
The extension loads cleanly even with nothing configured, and **never throws into your Pi session**. Each capability degrades independently:
|
|
53
|
+
|
|
54
|
+
| Capability | Requires | If missing |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| Auto-derived envs | `~/.aws/config` and/or `kubectl` on PATH | derives from whatever is present; if none found, `/aws-switch` says so |
|
|
57
|
+
| Kube context switch | a kube context + `kubectl` on PATH | AWS profile switch still applies; kube step reports failure |
|
|
58
|
+
| `/aws-whoami` | `aws` CLI on PATH | reports "aws unavailable", no crash |
|
|
59
|
+
| Prod guard | `prodAccounts` set in the optional config | **off, and says so on startup** — see below |
|
|
60
|
+
|
|
61
|
+
## The prod guard fails *honest*
|
|
62
|
+
|
|
63
|
+
If no `prodAccounts` are configured, the guard is **disabled and tells you so on startup** — it never guesses prod from a cluster name. You mark prod explicitly by account id, and an env is guarded when its account is in `prodAccounts` (or it sets `"prod": true` directly). When a prod env is active, any cloud command that isn't clearly read-only requires confirmation (fail-safe: unrecognized commands are treated as needing confirmation), and if the guard's own logic errors it **blocks** rather than silently allowing.
|
|
64
|
+
|
|
65
|
+
**The guard validates the real context on every command — even if it was switched in another terminal.** It reads the live `kubectl config current-context` at the moment each cloud command runs (and honors an inline `use-context` in the command itself) rather than trusting cached state, so it can't be dodged by starting a session already on prod, by the model switching itself via bash (even chained as `use-context prod && kubectl delete …`), or by a switch made in a separate shell outside Pi. The one honest limit: a context it can't map to a known prod env can't be judged prod.
|
|
66
|
+
|
|
67
|
+
## Security
|
|
68
|
+
|
|
69
|
+
Pi extensions run with your full system permissions and can execute arbitrary code. This one shells out to `aws` and `kubectl` and reads `~/.pi/agent/aws-accounts.json`. Review the source ([`src/index.ts`](./src/index.ts)) before installing — it's intentionally small.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "OPTIONAL. With no file at all, envs are auto-derived from ~/.aws/config (profiles) + your kube contexts, paired by AWS account id. Add this file ONLY to: (1) arm the prod guard via prodAccounts, (2) cover envs the derive can't see (a context with no matching profile, or a non-ARN context), or (3) add friendlier aliases. Stays local; never commit real account ids.",
|
|
3
|
+
"prodAccounts": ["111111111111"],
|
|
4
|
+
"envs": {
|
|
5
|
+
"stage": { "context": "arn:aws:eks:eu-central-1:222222222222:cluster/stage" }
|
|
6
|
+
}
|
|
7
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@showbegin/pi-aws-accounts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension: safe multi-account AWS + EKS context switching with a fail-honest prod guardrail and a visible active-account indicator.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "showbegin",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/showbegin/pi-aws-accounts.git" },
|
|
9
|
+
"bugs": { "url": "https://github.com/showbegin/pi-aws-accounts/issues" },
|
|
10
|
+
"homepage": "https://github.com/showbegin/pi-aws-accounts#readme",
|
|
11
|
+
"publishConfig": { "access": "public" },
|
|
12
|
+
"keywords": ["pi-package", "pi-extension", "aws", "eks", "kubectl", "devops", "guardrail"],
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": ["./src/index.ts"]
|
|
15
|
+
},
|
|
16
|
+
"files": ["src", "aws-accounts.example.json", "README.md", "LICENSE"],
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@earendil-works/pi-coding-agent": "^0.78.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
|
+
|
|
8
|
+
const pexec = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
type EnvDef = { profile?: string; context?: string; prod?: boolean; accountId?: string };
|
|
11
|
+
type Config = { envs?: Record<string, EnvDef>; prodAccounts?: string[] };
|
|
12
|
+
|
|
13
|
+
const CONFIG_PATH = join(homedir(), ".pi", "agent", "aws-accounts.json");
|
|
14
|
+
|
|
15
|
+
// Active selection. Set by the human (/aws-switch), by startup adoption of the live context,
|
|
16
|
+
// or by an in-session `kubectl config use-context`.
|
|
17
|
+
let current: ({ env: string } & EnvDef) | null = null;
|
|
18
|
+
|
|
19
|
+
// Config is OPTIONAL — everything is auto-derived. It only adds prod marking, friendly
|
|
20
|
+
// aliases, and manual pairing overrides.
|
|
21
|
+
let raw: Config = loadConfig();
|
|
22
|
+
// Effective env map: derived from ~/.aws/config + kube contexts, unioned with raw.envs.
|
|
23
|
+
let envs: Record<string, EnvDef> = {};
|
|
24
|
+
|
|
25
|
+
const TOUCHES_CLOUD = /\b(kubectl|aws|eksctl|helm)\b/;
|
|
26
|
+
const READONLY =
|
|
27
|
+
/\b(kubectl\s+(get|describe|logs|top|explain|version|cluster-info|api-resources|api-versions|config\s+(get-contexts|current-context|view|get-clusters))|aws\s+\S+\s+(describe|list|get)[\w-]*|aws\s+sts\s+get-caller-identity|helm\s+(list|status|get|history|version))\b/;
|
|
28
|
+
const MUTATING =
|
|
29
|
+
/\b(delete|destroy|terminate|apply|create|replace|patch|edit|scale|drain|cordon|uncordon|rollout|annotate|label|taint|exec|install|upgrade|uninstall|rollback|put-|update-|modify-|remove-|stop-|start-|reboot-|run-instances|detach-|attach-|deregister-|revoke-|disable-|enable-|associate-|disassociate-)\b/i;
|
|
30
|
+
|
|
31
|
+
function loadConfig(): Config {
|
|
32
|
+
try {
|
|
33
|
+
const c = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
34
|
+
return c && typeof c === "object" ? c : {};
|
|
35
|
+
} catch {
|
|
36
|
+
return {}; // absent/invalid config => pure zero-config mode
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// AWS account ids are 12 digits; pull from any arn (eks context or iam role_arn).
|
|
41
|
+
function acctOf(arn?: string): string | undefined {
|
|
42
|
+
return arn?.match(/:(\d{12}):/)?.[1];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// profile name -> account id, parsed from ~/.aws/config (account lives in role_arn).
|
|
46
|
+
function awsProfiles(): Record<string, string | undefined> {
|
|
47
|
+
const out: Record<string, string | undefined> = {};
|
|
48
|
+
try {
|
|
49
|
+
let cur = "";
|
|
50
|
+
for (const ln of readFileSync(join(homedir(), ".aws", "config"), "utf8").split("\n")) {
|
|
51
|
+
const line = ln.trim();
|
|
52
|
+
const sec = line.match(/^\[(?:profile\s+)?([^\]]+)\]$/);
|
|
53
|
+
if (sec) {
|
|
54
|
+
cur = sec[1].trim();
|
|
55
|
+
out[cur] = undefined;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const role = line.match(/^role_arn\s*=\s*(\S+)/);
|
|
59
|
+
if (role && cur) out[cur] = acctOf(role[1]);
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
/* no ~/.aws/config */
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function kubeContexts(): Promise<string[]> {
|
|
68
|
+
try {
|
|
69
|
+
return (await pexec("kubectl", ["config", "get-contexts", "-o", "name"])).stdout
|
|
70
|
+
.split("\n")
|
|
71
|
+
.map((s) => s.trim())
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Build one env per kube context (so multiple clusters under one account each get an entry),
|
|
79
|
+
// pairing each to an AWS profile by account id. Union explicit raw.envs; mark prod by account.
|
|
80
|
+
async function rebuild(): Promise<void> {
|
|
81
|
+
const profiles = awsProfiles();
|
|
82
|
+
const profByAcct: Record<string, string> = {};
|
|
83
|
+
for (const [n, a] of Object.entries(profiles)) if (a && !(a in profByAcct)) profByAcct[a] = n;
|
|
84
|
+
const merged: Record<string, EnvDef> = {};
|
|
85
|
+
for (const c of await kubeContexts()) {
|
|
86
|
+
const accountId = acctOf(c);
|
|
87
|
+
let key = clusterName(c);
|
|
88
|
+
if (merged[key]) key = `${key}-${accountId || "x"}`; // disambiguate duplicate cluster names
|
|
89
|
+
merged[key] = { profile: accountId ? profByAcct[accountId] : undefined, context: c, accountId };
|
|
90
|
+
}
|
|
91
|
+
for (const [name, e] of Object.entries(raw.envs || {})) {
|
|
92
|
+
merged[name] = { ...merged[name], ...e, accountId: e.accountId || acctOf(e.context) || merged[name]?.accountId };
|
|
93
|
+
}
|
|
94
|
+
const prod = new Set((raw.prodAccounts || []).map(String));
|
|
95
|
+
for (const e of Object.values(merged)) {
|
|
96
|
+
if (e.prod !== true && e.accountId && prod.has(e.accountId)) e.prod = true;
|
|
97
|
+
}
|
|
98
|
+
envs = merged;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function shortCtx(c?: string): string {
|
|
102
|
+
if (!c) return "-";
|
|
103
|
+
const p = c.split("/");
|
|
104
|
+
return p[p.length - 1].slice(0, 24);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function clusterName(ctx: string): string {
|
|
108
|
+
const m = ctx.match(/cluster\/(.+)$/);
|
|
109
|
+
return m ? m[1] : ctx;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Picker label: "<key> — <profile|account> · <cluster> [PROD]". Includes the key so labels are unique.
|
|
113
|
+
function labelFor(k: string): string {
|
|
114
|
+
const e = envs[k];
|
|
115
|
+
const who = e.profile || "no-profile";
|
|
116
|
+
const acct = e.accountId ? ` (${e.accountId})` : "";
|
|
117
|
+
const cl = e.context ? clusterName(e.context) : "(no cluster)";
|
|
118
|
+
return `${k} — ${who}${acct} · ${cl}${e.prod ? " [PROD]" : ""}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function setStatus(ctx: ExtensionContext): void {
|
|
122
|
+
if (!current) {
|
|
123
|
+
ctx.ui.setStatus("aws-accounts", "aws: no env selected");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const tag = current.prod ? "PROD\u26a0" : current.env;
|
|
127
|
+
ctx.ui.setStatus("aws-accounts", `aws:${tag} [${current.profile || "-"}] k8s:${shortCtx(current.context)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default function (pi: ExtensionAPI) {
|
|
131
|
+
pi.on("session_start", async (_e, ctx) => {
|
|
132
|
+
try {
|
|
133
|
+
raw = loadConfig();
|
|
134
|
+
await rebuild();
|
|
135
|
+
// Adopt the live kube context so we never show "no env" while actually pointed at prod.
|
|
136
|
+
try {
|
|
137
|
+
const live = (await pexec("kubectl", ["config", "current-context"])).stdout.trim();
|
|
138
|
+
if (live) {
|
|
139
|
+
const hit = Object.entries(envs).find(([, e]) => e.context === live);
|
|
140
|
+
current = hit
|
|
141
|
+
? { env: hit[0], ...hit[1], context: live, profile: process.env.AWS_PROFILE }
|
|
142
|
+
: { env: "(unmapped)", context: live, profile: process.env.AWS_PROFILE };
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
/* kubectl absent */
|
|
146
|
+
}
|
|
147
|
+
setStatus(ctx);
|
|
148
|
+
const n = Object.keys(envs).length;
|
|
149
|
+
if (n === 0)
|
|
150
|
+
ctx.ui.notify("pi-aws-accounts: no AWS profiles or kube contexts detected — nothing to switch (see README).", "info");
|
|
151
|
+
else if (current?.prod)
|
|
152
|
+
ctx.ui.notify(`\u26a0 on PROD context (${current.env}) — guard armed.`, "info");
|
|
153
|
+
else if (!Object.values(envs).some((e) => e.prod))
|
|
154
|
+
ctx.ui.notify(`pi-aws-accounts: ${n} env(s) ready. Prod guard OFF — add "prodAccounts":["<id>"] to ${CONFIG_PATH} to arm it.`, "info");
|
|
155
|
+
} catch {
|
|
156
|
+
/* never break session startup */
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
pi.registerCommand("aws-switch", {
|
|
161
|
+
description: "Switch active AWS profile + kube context, e.g. /aws-switch prod",
|
|
162
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
163
|
+
try {
|
|
164
|
+
if (Object.keys(envs).length === 0) await rebuild();
|
|
165
|
+
const names = Object.keys(envs);
|
|
166
|
+
if (names.length === 0) {
|
|
167
|
+
ctx.ui.notify("no AWS/EKS envs detected — nothing to switch (see README).", "info");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
let name = (args || "").trim();
|
|
171
|
+
if (!name) {
|
|
172
|
+
const labels = names.map(labelFor);
|
|
173
|
+
const picked = await ctx.ui.select("Switch AWS / EKS context", labels);
|
|
174
|
+
if (!picked) return;
|
|
175
|
+
name = names[labels.indexOf(picked)];
|
|
176
|
+
}
|
|
177
|
+
const e = envs[name];
|
|
178
|
+
if (!e) {
|
|
179
|
+
ctx.ui.notify(`unknown env "${name}". available: ${names.join(", ") || "(none)"}`, "error");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Always clear stale static creds so the profile (or default) wins.
|
|
183
|
+
delete process.env.AWS_ACCESS_KEY_ID;
|
|
184
|
+
delete process.env.AWS_SECRET_ACCESS_KEY;
|
|
185
|
+
delete process.env.AWS_SESSION_TOKEN;
|
|
186
|
+
// Unset AWS_PROFILE for context-only envs, so kube never drifts from a stale AWS account.
|
|
187
|
+
if (e.profile) process.env.AWS_PROFILE = e.profile;
|
|
188
|
+
else delete process.env.AWS_PROFILE;
|
|
189
|
+
let note = "";
|
|
190
|
+
if (e.context) {
|
|
191
|
+
try {
|
|
192
|
+
await pexec("kubectl", ["config", "use-context", e.context]);
|
|
193
|
+
} catch {
|
|
194
|
+
note = " (kube switch failed: kubectl or context unavailable)";
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
current = { env: name, ...e };
|
|
198
|
+
setStatus(ctx);
|
|
199
|
+
ctx.ui.notify(`switched to ${name}${e.prod ? " \u26a0 [PROD — guard armed]" : ""}${note}`, "info");
|
|
200
|
+
} catch (err: any) {
|
|
201
|
+
ctx.ui.notify("aws-switch failed: " + (err?.message || String(err)), "error");
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
pi.registerCommand("aws-whoami", {
|
|
207
|
+
description: "Show active AWS identity + kube context",
|
|
208
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
209
|
+
try {
|
|
210
|
+
let id: string;
|
|
211
|
+
try {
|
|
212
|
+
id = (await pexec("aws", ["sts", "get-caller-identity", "--output", "text"])).stdout.trim();
|
|
213
|
+
} catch (e: any) {
|
|
214
|
+
id = "aws unavailable (" + (e?.message || e) + ")";
|
|
215
|
+
}
|
|
216
|
+
let kc: string;
|
|
217
|
+
try {
|
|
218
|
+
kc = (await pexec("kubectl", ["config", "current-context"])).stdout.trim();
|
|
219
|
+
} catch (e: any) {
|
|
220
|
+
kc = "kubectl unavailable (" + (e?.message || e) + ")";
|
|
221
|
+
}
|
|
222
|
+
ctx.ui.notify(
|
|
223
|
+
`env: ${current?.env || "none"}\nprofile: ${process.env.AWS_PROFILE || "(default)"}\nidentity: ${id}\nkube-context: ${kc}`,
|
|
224
|
+
"info",
|
|
225
|
+
);
|
|
226
|
+
} catch (err: any) {
|
|
227
|
+
ctx.ui.notify("aws-whoami failed: " + (err?.message || String(err)), "error");
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
pi.registerCommand("aws-set-prod", {
|
|
233
|
+
description: "Mark a cluster as production (arms the prod guard for it)",
|
|
234
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
235
|
+
try {
|
|
236
|
+
if (Object.keys(envs).length === 0) await rebuild();
|
|
237
|
+
const names = Object.keys(envs);
|
|
238
|
+
if (names.length === 0) {
|
|
239
|
+
ctx.ui.notify("no envs detected — nothing to mark.", "info");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const labels = names.map(labelFor);
|
|
243
|
+
const picked = await ctx.ui.select("Mark which cluster is PRODUCTION", labels);
|
|
244
|
+
if (!picked) return;
|
|
245
|
+
const key = names[labels.indexOf(picked)];
|
|
246
|
+
const cfg = loadConfig();
|
|
247
|
+
cfg.envs = cfg.envs || {};
|
|
248
|
+
cfg.envs[key] = { ...cfg.envs[key], prod: true };
|
|
249
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
250
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
|
|
251
|
+
raw = cfg;
|
|
252
|
+
await rebuild();
|
|
253
|
+
if (current?.env === key) {
|
|
254
|
+
current.prod = true;
|
|
255
|
+
setStatus(ctx);
|
|
256
|
+
}
|
|
257
|
+
ctx.ui.notify(`marked "${key}" as PROD — guard armed. Saved to ${CONFIG_PATH}`, "info");
|
|
258
|
+
} catch (err: any) {
|
|
259
|
+
ctx.ui.notify("aws-set-prod failed: " + (err?.message || String(err)), "error");
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Prod guardrail. Confirms/blocks mutating aws|kubectl|helm that will run against a prod context.
|
|
265
|
+
// Resolves the REAL kube context at command time, so a switch from ANY terminal is caught.
|
|
266
|
+
pi.on("tool_call", async (event: any, ctx: ExtensionContext) => {
|
|
267
|
+
try {
|
|
268
|
+
if (event.toolName !== "bash") return;
|
|
269
|
+
const cmd: string = event.input?.command || "";
|
|
270
|
+
if (!TOUCHES_CLOUD.test(cmd)) return;
|
|
271
|
+
|
|
272
|
+
// Which kube context will this command actually hit?
|
|
273
|
+
// inline `use-context` wins; else the live current-context (catches external switches);
|
|
274
|
+
// else the last-known context (if kubectl is unavailable).
|
|
275
|
+
const inline = cmd.match(/kubectl\s+config\s+use-context\s+["']?([^"'\s;&|]+)/);
|
|
276
|
+
let liveCtx = inline?.[1];
|
|
277
|
+
if (!liveCtx) {
|
|
278
|
+
try {
|
|
279
|
+
liveCtx = (await pexec("kubectl", ["config", "current-context"])).stdout.trim();
|
|
280
|
+
} catch {
|
|
281
|
+
liveCtx = current?.context;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const hit = liveCtx ? Object.entries(envs).find(([, e]) => e.context === liveCtx) : undefined;
|
|
285
|
+
|
|
286
|
+
// Sync the indicator to the resolved live context FIRST, so it's truthful even if we block.
|
|
287
|
+
if (liveCtx && current?.context !== liveCtx) {
|
|
288
|
+
current = hit
|
|
289
|
+
? { env: hit[0], ...hit[1], context: liveCtx, profile: process.env.AWS_PROFILE }
|
|
290
|
+
: { env: "(unmapped)", context: liveCtx, profile: process.env.AWS_PROFILE };
|
|
291
|
+
setStatus(ctx);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const readOnly = READONLY.test(cmd) && !MUTATING.test(cmd);
|
|
295
|
+
if (hit?.[1].prod && !readOnly) {
|
|
296
|
+
const ok = await ctx.ui.confirm(
|
|
297
|
+
"\u26a0 PROD guard",
|
|
298
|
+
`This runs against PROD (${hit[0]}).\nAllow this command?\n\n${cmd}`,
|
|
299
|
+
);
|
|
300
|
+
if (!ok) return { block: true, reason: "Blocked by pi-aws-accounts prod guard" };
|
|
301
|
+
}
|
|
302
|
+
} catch (err: any) {
|
|
303
|
+
// Fail closed: if the guard itself errors, block rather than silently allow.
|
|
304
|
+
return { block: true, reason: "prod guard error: " + (err?.message || String(err)) };
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|