az-brake 0.0.0-stage → 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/.claude-plugin/marketplace.json +10 -0
- package/.claude-plugin/plugin.json +7 -0
- package/LICENSE +21 -0
- package/README.md +54 -2
- package/dist/classify.js +263 -0
- package/dist/hook.js +30 -0
- package/hooks/hooks.json +18 -0
- package/package.json +44 -4
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "az-brake",
|
|
3
|
+
"description": "Asks before Claude runs an Azure CLI command that creates or starts a resource that costs money.",
|
|
4
|
+
"owner": { "name": "thisismairaj", "url": "https://github.com/thisismairaj" },
|
|
5
|
+
"plugins": [{
|
|
6
|
+
"name": "az-brake",
|
|
7
|
+
"source": "./",
|
|
8
|
+
"description": "Asks before Claude runs an Azure CLI command that creates or starts a resource that costs money."
|
|
9
|
+
}]
|
|
10
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 thisismairaj
|
|
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
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
#
|
|
1
|
+
# az-brake
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Claude Code plugin that **asks before Claude runs an Azure CLI command that creates or starts a resource that costs money.**
|
|
4
|
+
|
|
5
|
+
It only guards commands Claude runs. It does not guard the Azure portal, Terraform, scheduled jobs, or you typing in a terminal. And it reads command text, so it catches well-known shapes, not everything. It is a speed bump, not a security boundary.
|
|
6
|
+
|
|
7
|
+
## Why this matters
|
|
8
|
+
|
|
9
|
+
Azure bills for what exists and what runs, and the controls are mostly after the fact: a budget alert is an email, it does not stop anything. A coding agent can create a database, a cluster or a VM in one line. This asks you first, and names the resource.
|
|
10
|
+
|
|
11
|
+
## What it asks about
|
|
12
|
+
|
|
13
|
+
Everything else (reads, `list`, `show`, deletes, `--help`, resource groups, virtual networks, `az login`) runs without a prompt, because a hook that asks too often gets turned off.
|
|
14
|
+
|
|
15
|
+
- **Creating resources that bill:** virtual machines and scale sets, disks and snapshots, storage accounts, AKS clusters and node pools, container registries, SQL databases, managed instances, elastic pools and dedicated pools, PostgreSQL and MySQL flexible servers, Cosmos DB, Redis, container apps and instances, web apps, function apps, App Service plans, Synapse, Data Factory, Event Hubs, Service Bus, AI Search, Azure AI services, Databricks workspaces, Log Analytics, Application Insights, public IPs, gateways, firewalls, NAT gateways, load balancers, Bastion, private endpoints, API Management, Front Door and CDN, and more (about 50 types, listed in `src/classify.ts`).
|
|
16
|
+
- **Starting, scaling or resizing them:** `start`, `resume`, `scale`, `resize`, `aks nodepool add`, and an `update` that changes a size or tier.
|
|
17
|
+
- **Commands that can create anything:** `az deployment ... create`, `az stack ... create`, `az resource create`, `az rest --method put`, `az webapp up` and `az containerapp up`. The prompt says it cannot see what a template contains. `--what-if` is not asked about.
|
|
18
|
+
- **Not asked about on purpose:** `az group create` and `az sql server create` (free), and data movement such as `az storage blob upload-batch` (each operation costs a tiny amount).
|
|
19
|
+
|
|
20
|
+
The prompt names each resource it can see: what it creates, its `--name`, and the size, SKU and location flags if present. In a chained command (`a && b`) every billable step is listed. It never shows a price, because the hook cannot see your rates or discounts.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
/plugin install az-brake --marketplace thisismairaj/az-brake
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Nothing to configure. Needs Node 20 or newer on your PATH.
|
|
29
|
+
|
|
30
|
+
## How it works
|
|
31
|
+
|
|
32
|
+
A `PreToolUse` hook on `Bash|PowerShell` (both, because on Windows without Git Bash only PowerShell exists). If a command looks like it can create or start a billable resource, the hook answers `ask`, and Claude Code shows its normal approval prompt. Any error inside the hook fails open: the command runs.
|
|
33
|
+
|
|
34
|
+
## Tested live (headless `claude -p`, Claude Code on Windows)
|
|
35
|
+
|
|
36
|
+
- A `az storage account create` command is stopped by the hook in `dontAsk` and `bypassPermissions` modes. The command was pointed at a resource group that does not exist, so nothing could have been created if it had run.
|
|
37
|
+
- A free command (`az group exists`) is not stopped.
|
|
38
|
+
- 95 unit cases pass, and every command path in the table was checked against the installed Azure CLI (2.91.0) with `az <path> -h`. Ten of those paths come from Azure CLI extensions that the CLI offers to install on first use.
|
|
39
|
+
|
|
40
|
+
Headless mode cannot answer a prompt, so these runs show the hook fires and the command does not run. They do not show the interactive approval dialog.
|
|
41
|
+
|
|
42
|
+
## Not verified yet
|
|
43
|
+
|
|
44
|
+
- The interactive approval dialog itself (approving a flagged command and seeing it run).
|
|
45
|
+
- The PowerShell tool path, and Git Bash vs PowerShell on a machine without Git.
|
|
46
|
+
- macOS and Linux (development was on Windows only).
|
|
47
|
+
- Commands wrapped in another shell, such as `bash -c "az vm create ..."`, are not read.
|
|
48
|
+
|
|
49
|
+
## Development
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
npm install
|
|
53
|
+
npm test # 95 cases, runs the TypeScript directly
|
|
54
|
+
npm run build # compiles src/ to dist/ (dist/ is committed so installs need no build)
|
|
55
|
+
```
|
package/dist/classify.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Decides whether a shell command can create or start an Azure resource that costs money.
|
|
2
|
+
// Pure logic, no I/O, so it can be tested with plain strings.
|
|
3
|
+
//
|
|
4
|
+
// Heuristic, not a security boundary: it reads command text, so it only catches well-known
|
|
5
|
+
// shapes. False negatives are expected. Anything it does not recognise passes.
|
|
6
|
+
const PASS = { level: 'pass', reasons: [] };
|
|
7
|
+
// Words that can sit in front of the real tool, e.g. `sudo az vm create` or `& az vm create`.
|
|
8
|
+
const WRAPPERS = new Set(['sudo', 'time', 'command', 'exec', 'env', 'call', '&', 'nice']);
|
|
9
|
+
// A line continuation (backslash in bash, backtick in PowerShell) joins two lines into one command.
|
|
10
|
+
function joinContinuations(command) {
|
|
11
|
+
return command.replace(/(?:\\|`)\r?\n/g, ' ');
|
|
12
|
+
}
|
|
13
|
+
export function splitSegments(command) {
|
|
14
|
+
const segments = [];
|
|
15
|
+
let current = '';
|
|
16
|
+
let quote = null;
|
|
17
|
+
const text = joinContinuations(command);
|
|
18
|
+
for (let i = 0; i < text.length; i++) {
|
|
19
|
+
const c = text[i];
|
|
20
|
+
if (quote) {
|
|
21
|
+
if (c === quote)
|
|
22
|
+
quote = null;
|
|
23
|
+
current += c;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (c === '"' || c === "'") {
|
|
27
|
+
quote = c;
|
|
28
|
+
current += c;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const two = text.slice(i, i + 2);
|
|
32
|
+
if (two === '&&' || two === '||') {
|
|
33
|
+
segments.push(current);
|
|
34
|
+
current = '';
|
|
35
|
+
i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (c === ';' || c === '|' || c === '\n') {
|
|
39
|
+
segments.push(current);
|
|
40
|
+
current = '';
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
current += c;
|
|
44
|
+
}
|
|
45
|
+
segments.push(current);
|
|
46
|
+
return segments.map(s => s.trim()).filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
export function tokenize(segment) {
|
|
49
|
+
const tokens = [];
|
|
50
|
+
let current = '';
|
|
51
|
+
let quote = null;
|
|
52
|
+
let has = false;
|
|
53
|
+
for (const c of segment) {
|
|
54
|
+
if (quote) {
|
|
55
|
+
if (c === quote)
|
|
56
|
+
quote = null;
|
|
57
|
+
else
|
|
58
|
+
current += c;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (c === '"' || c === "'") {
|
|
62
|
+
quote = c;
|
|
63
|
+
has = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (/\s/.test(c)) {
|
|
67
|
+
if (has)
|
|
68
|
+
tokens.push(current);
|
|
69
|
+
current = '';
|
|
70
|
+
has = false;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
current += c;
|
|
74
|
+
has = true;
|
|
75
|
+
}
|
|
76
|
+
if (has)
|
|
77
|
+
tokens.push(current);
|
|
78
|
+
return tokens;
|
|
79
|
+
}
|
|
80
|
+
function toolName(token) {
|
|
81
|
+
const base = token.split(/[\\/]/).pop() ?? token;
|
|
82
|
+
return base.toLowerCase().replace(/\.(exe|cmd|bat|ps1)$/, '');
|
|
83
|
+
}
|
|
84
|
+
function findTool(tokens) {
|
|
85
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
86
|
+
const t = tokens[i];
|
|
87
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t))
|
|
88
|
+
continue;
|
|
89
|
+
if (WRAPPERS.has(t.toLowerCase()))
|
|
90
|
+
continue;
|
|
91
|
+
return { name: toolName(t), rest: tokens.slice(i + 1) };
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const isHelp = (rest) => rest.some(t => t === '--help' || t === '-h');
|
|
96
|
+
// `az <group> [<subgroup>] <verb> --flags`: the command words come first, then flags.
|
|
97
|
+
function commandPath(rest) {
|
|
98
|
+
const path = [];
|
|
99
|
+
for (const t of rest) {
|
|
100
|
+
if (t.startsWith('-'))
|
|
101
|
+
break;
|
|
102
|
+
path.push(t.toLowerCase());
|
|
103
|
+
}
|
|
104
|
+
return path;
|
|
105
|
+
}
|
|
106
|
+
function flagValue(rest, names) {
|
|
107
|
+
for (let i = 0; i < rest.length; i++) {
|
|
108
|
+
const t = rest[i];
|
|
109
|
+
for (const n of names) {
|
|
110
|
+
if (t === n)
|
|
111
|
+
return rest[i + 1];
|
|
112
|
+
if (t.startsWith(n + '='))
|
|
113
|
+
return t.slice(n.length + 1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
const hasFlag = (rest, names) => rest.some(t => names.some(n => t === n || t.startsWith(n + '=')));
|
|
119
|
+
const DEFAULT_VERBS = ['create', 'start', 'resume', 'scale', 'resize'];
|
|
120
|
+
const GENERIC_NOTE = 'This type of resource can bill for as long as it exists.';
|
|
121
|
+
// Only types that bill for existing or running. Free ones (resource groups, vnets, subnets,
|
|
122
|
+
// network security groups, `sql server`, managed identities, role assignments) are left out on purpose.
|
|
123
|
+
const RESOURCES = [
|
|
124
|
+
{ prefix: 'vm', label: 'virtual machine', note: 'Compute bills while it is allocated, and its disks bill until deleted.' },
|
|
125
|
+
{ prefix: 'vmss', label: 'VM scale set', note: 'Every instance bills like a virtual machine.' },
|
|
126
|
+
{ prefix: 'disk', label: 'managed disk', note: 'Bills for its size until deleted.' },
|
|
127
|
+
{ prefix: 'snapshot', label: 'disk snapshot', note: 'Bills for stored data until deleted.' },
|
|
128
|
+
{ prefix: 'storage account', label: 'storage account', note: 'Bills for stored data and for operations.' },
|
|
129
|
+
{ prefix: 'aks', label: 'Kubernetes cluster (AKS)', note: 'The nodes are virtual machines and bill while they run.' },
|
|
130
|
+
{ prefix: 'aks nodepool', label: 'AKS node pool', note: 'Each node is a virtual machine and bills while it runs.', verbs: [...DEFAULT_VERBS, 'add'] },
|
|
131
|
+
{ prefix: 'acr', label: 'container registry', note: 'Bills a daily fee by tier, plus storage.', verbs: ['create', 'build'] },
|
|
132
|
+
{ prefix: 'sql db', label: 'SQL database', note: 'Bills while it exists, by service tier.' },
|
|
133
|
+
{ prefix: 'sql mi', label: 'SQL managed instance', note: 'Bills while it exists and is expensive to leave running.' },
|
|
134
|
+
{ prefix: 'sql elastic-pool', label: 'SQL elastic pool', note: 'Bills while it exists.' },
|
|
135
|
+
{ prefix: 'sql dw', label: 'dedicated SQL pool', note: 'Bills while it is running.' },
|
|
136
|
+
{ prefix: 'postgres flexible-server', label: 'PostgreSQL flexible server', note: 'Bills while it runs, plus storage.' },
|
|
137
|
+
{ prefix: 'mysql flexible-server', label: 'MySQL flexible server', note: 'Bills while it runs, plus storage.' },
|
|
138
|
+
{ prefix: 'cosmosdb', label: 'Cosmos DB account', note: 'Bills for provisioned or consumed throughput and storage.' },
|
|
139
|
+
{ prefix: 'redis', label: 'Redis cache', note: 'Bills per hour while it exists.' },
|
|
140
|
+
{ prefix: 'containerapp', label: 'container app' },
|
|
141
|
+
{ prefix: 'containerapp env', label: 'container apps environment' },
|
|
142
|
+
{ prefix: 'container', label: 'container instance', note: 'Bills per second while it runs.' },
|
|
143
|
+
{ prefix: 'webapp', label: 'web app' },
|
|
144
|
+
{ prefix: 'functionapp', label: 'function app' },
|
|
145
|
+
{ prefix: 'appservice plan', label: 'App Service plan', note: 'Bills while it exists, whatever runs on it.' },
|
|
146
|
+
{ prefix: 'synapse workspace', label: 'Synapse workspace' },
|
|
147
|
+
{ prefix: 'synapse spark pool', label: 'Synapse Spark pool' },
|
|
148
|
+
{ prefix: 'synapse sql pool', label: 'Synapse dedicated SQL pool', note: 'Bills while it is running.' },
|
|
149
|
+
{ prefix: 'datafactory', label: 'Data Factory' },
|
|
150
|
+
{ prefix: 'eventhubs namespace', label: 'Event Hubs namespace' },
|
|
151
|
+
{ prefix: 'servicebus namespace', label: 'Service Bus namespace' },
|
|
152
|
+
{ prefix: 'search service', label: 'AI Search service', note: 'Bills per hour by tier while it exists.' },
|
|
153
|
+
{ prefix: 'cognitiveservices account', label: 'Azure AI services account' },
|
|
154
|
+
{ prefix: 'ml compute', label: 'Azure ML compute', note: 'Compute bills while it runs.' },
|
|
155
|
+
{ prefix: 'batch account', label: 'Batch account' },
|
|
156
|
+
{ prefix: 'databricks workspace', label: 'Databricks workspace' },
|
|
157
|
+
{ prefix: 'kusto cluster', label: 'Data Explorer cluster', note: 'Bills while it runs.' },
|
|
158
|
+
{ prefix: 'monitor log-analytics workspace', label: 'Log Analytics workspace', note: 'Bills for data ingested and retained.' },
|
|
159
|
+
{ prefix: 'monitor app-insights component', label: 'Application Insights resource', note: 'Bills for data ingested.' },
|
|
160
|
+
{ prefix: 'network public-ip', label: 'public IP address', note: 'Standard-tier addresses bill per hour.' },
|
|
161
|
+
{ prefix: 'network vnet-gateway', label: 'virtual network gateway', note: 'Bills per hour while it exists.' },
|
|
162
|
+
{ prefix: 'network firewall', label: 'Azure Firewall', note: 'Bills per hour while it exists.' },
|
|
163
|
+
{ prefix: 'network nat gateway', label: 'NAT gateway', note: 'Bills per hour while it exists.' },
|
|
164
|
+
{ prefix: 'network application-gateway', label: 'application gateway', note: 'Bills per hour while it exists.' },
|
|
165
|
+
{ prefix: 'network lb', label: 'load balancer' },
|
|
166
|
+
{ prefix: 'network bastion', label: 'Bastion host', note: 'Bills per hour while it exists.' },
|
|
167
|
+
{ prefix: 'network private-endpoint', label: 'private endpoint', note: 'Bills per hour while it exists.' },
|
|
168
|
+
{ prefix: 'cdn profile', label: 'CDN profile' },
|
|
169
|
+
{ prefix: 'afd profile', label: 'Front Door profile' },
|
|
170
|
+
{ prefix: 'apim', label: 'API Management service', note: 'Bills per hour by tier while it exists.' },
|
|
171
|
+
{ prefix: 'backup vault', label: 'backup vault' },
|
|
172
|
+
{ prefix: 'netappfiles account', label: 'NetApp Files account' },
|
|
173
|
+
];
|
|
174
|
+
// Longest prefix first, so `aks nodepool add` is read as a node pool and not as `aks`.
|
|
175
|
+
const BY_PREFIX = [...RESOURCES].sort((a, b) => b.prefix.split(' ').length - a.prefix.split(' ').length);
|
|
176
|
+
// Flags that change how big, and so how expensive, an existing resource is.
|
|
177
|
+
const SIZE_FLAGS = [
|
|
178
|
+
'--sku', '--sku-name', '--size', '--vm-size', '--node-vm-size', '--node-count', '--tier',
|
|
179
|
+
'--capacity', '--instance-count', '--service-objective', '--edition', '--max-size',
|
|
180
|
+
'--storage-size', '--throughput', '--size-gb',
|
|
181
|
+
];
|
|
182
|
+
// Flags worth showing in the prompt, so you can see what you are approving.
|
|
183
|
+
const DETAIL_FLAGS = [
|
|
184
|
+
['sku', ['--sku', '--sku-name']],
|
|
185
|
+
['size', ['--size', '--vm-size', '--node-vm-size']],
|
|
186
|
+
['nodes', ['--node-count', '--instance-count']],
|
|
187
|
+
['tier', ['--tier', '--edition', '--service-objective']],
|
|
188
|
+
['location', ['--location', '-l']],
|
|
189
|
+
];
|
|
190
|
+
// Commands that can create any resource, or that create several at once.
|
|
191
|
+
const ALWAYS_ASK = [
|
|
192
|
+
{ prefix: 'deployment group create', why: 'deploy a template, which can create resources of any type', skipIf: ['--what-if', '-w'] },
|
|
193
|
+
{ prefix: 'deployment sub create', why: 'deploy a template, which can create resources of any type', skipIf: ['--what-if', '-w'] },
|
|
194
|
+
{ prefix: 'deployment mg create', why: 'deploy a template, which can create resources of any type', skipIf: ['--what-if', '-w'] },
|
|
195
|
+
{ prefix: 'deployment tenant create', why: 'deploy a template, which can create resources of any type', skipIf: ['--what-if', '-w'] },
|
|
196
|
+
{ prefix: 'group deployment create', why: 'deploy a template, which can create resources of any type' },
|
|
197
|
+
{ prefix: 'stack group create', why: 'create a deployment stack, which can create resources of any type' },
|
|
198
|
+
{ prefix: 'stack sub create', why: 'create a deployment stack, which can create resources of any type' },
|
|
199
|
+
{ prefix: 'resource create', why: 'create a resource of any type from raw properties' },
|
|
200
|
+
{ prefix: 'webapp up', why: 'create a resource group, an App Service plan and a web app if they do not exist' },
|
|
201
|
+
{ prefix: 'containerapp up', why: 'create an environment, a registry and a container app if they do not exist' },
|
|
202
|
+
];
|
|
203
|
+
function detailsOf(rest) {
|
|
204
|
+
const parts = [];
|
|
205
|
+
for (const [label, flags] of DETAIL_FLAGS) {
|
|
206
|
+
const value = flagValue(rest, flags);
|
|
207
|
+
if (value)
|
|
208
|
+
parts.push(`${label} ${value}`);
|
|
209
|
+
}
|
|
210
|
+
return parts.join(', ');
|
|
211
|
+
}
|
|
212
|
+
const startsWithWords = (path, prefix) => {
|
|
213
|
+
const words = prefix.split(' ');
|
|
214
|
+
return words.every((w, i) => path[i] === w);
|
|
215
|
+
};
|
|
216
|
+
function classifyAz(rest) {
|
|
217
|
+
if (isHelp(rest))
|
|
218
|
+
return [];
|
|
219
|
+
const path = commandPath(rest);
|
|
220
|
+
if (path.length === 0)
|
|
221
|
+
return [];
|
|
222
|
+
for (const rule of ALWAYS_ASK) {
|
|
223
|
+
if (startsWithWords(path, rule.prefix) && !(rule.skipIf && hasFlag(rest, rule.skipIf))) {
|
|
224
|
+
return [`${rule.why}. The prompt cannot show what the template or properties contain`];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (path[0] === 'rest' && (flagValue(rest, ['--method', '-m']) ?? '').toLowerCase() === 'put') {
|
|
228
|
+
return ['send an ARM PUT request, which can create a resource of any type'];
|
|
229
|
+
}
|
|
230
|
+
for (const res of BY_PREFIX) {
|
|
231
|
+
if (!startsWithWords(path, res.prefix))
|
|
232
|
+
continue;
|
|
233
|
+
const verb = path[res.prefix.split(' ').length];
|
|
234
|
+
if (!verb)
|
|
235
|
+
return [];
|
|
236
|
+
const verbs = res.verbs ?? DEFAULT_VERBS;
|
|
237
|
+
const isUpdate = verb === 'update' && hasFlag(rest, SIZE_FLAGS);
|
|
238
|
+
if (!verbs.includes(verb) && !isUpdate)
|
|
239
|
+
continue;
|
|
240
|
+
const name = flagValue(rest, ['--name', '-n']);
|
|
241
|
+
const details = detailsOf(rest);
|
|
242
|
+
const action = isUpdate ? 'change the size of' : verb === 'build' ? 'run a billed build in' : verb;
|
|
243
|
+
const text = `${action} ${res.label}` +
|
|
244
|
+
(name ? ` "${name}"` : '') +
|
|
245
|
+
(details ? ` (${details})` : '') +
|
|
246
|
+
`. ${res.note ?? GENERIC_NOTE}`;
|
|
247
|
+
return [text];
|
|
248
|
+
}
|
|
249
|
+
// `az vm disk attach --new` creates a new billed disk.
|
|
250
|
+
if (startsWithWords(path, 'vm disk attach') && hasFlag(rest, ['--new'])) {
|
|
251
|
+
return ['create a new managed disk and attach it to a VM. It bills for its size until deleted'];
|
|
252
|
+
}
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
export function classify(command) {
|
|
256
|
+
const reasons = [];
|
|
257
|
+
for (const segment of splitSegments(command)) {
|
|
258
|
+
const tool = findTool(tokenize(segment));
|
|
259
|
+
if (tool?.name === 'az')
|
|
260
|
+
reasons.push(...classifyAz(tool.rest));
|
|
261
|
+
}
|
|
262
|
+
return reasons.length ? { level: 'ask', reasons } : PASS;
|
|
263
|
+
}
|
package/dist/hook.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// PreToolUse command hook. Reads the hook payload on stdin and, if the command can create or
|
|
2
|
+
// start an Azure resource that costs money, answers "ask" so Claude Code shows its normal
|
|
3
|
+
// approval prompt. Any failure here must fail OPEN: print nothing, exit 0, let the command run.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { classify } from "./classify.js";
|
|
6
|
+
function main() {
|
|
7
|
+
const payload = JSON.parse(readFileSync(0, 'utf8'));
|
|
8
|
+
const command = payload.tool_input?.command;
|
|
9
|
+
if (typeof command !== 'string')
|
|
10
|
+
return;
|
|
11
|
+
const verdict = classify(command);
|
|
12
|
+
if (verdict.level !== 'ask')
|
|
13
|
+
return;
|
|
14
|
+
const steps = verdict.reasons.map((r, i) => (verdict.reasons.length > 1 ? `(${i + 1}) ` : '') + r);
|
|
15
|
+
const reason = `az-brake: this command would ${steps.join(' ')} ` +
|
|
16
|
+
`az-brake cannot see your Azure prices or discounts, so no cost estimate is shown. Approve?`;
|
|
17
|
+
process.stdout.write(JSON.stringify({
|
|
18
|
+
hookSpecificOutput: {
|
|
19
|
+
hookEventName: 'PreToolUse',
|
|
20
|
+
permissionDecision: 'ask',
|
|
21
|
+
permissionDecisionReason: reason,
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
main();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// fail open: a bug in this hook must never be the reason a command is blocked
|
|
30
|
+
}
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Asks before a Bash or PowerShell command creates or starts an Azure resource that costs money.",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"PreToolUse": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "Bash|PowerShell",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"command": "node",
|
|
11
|
+
"args": ["${CLAUDE_PLUGIN_ROOT}/dist/hook.js"],
|
|
12
|
+
"timeout": 10
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "az-brake",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code plugin that asks before an Azure CLI command creates or starts a resource that costs money.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "thisismairaj",
|
|
8
|
+
"url": "https://github.com/thisismairaj"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"test": "node --test \"test/*.test.ts\""
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/node": "^24.0.0",
|
|
17
|
+
"typescript": "^5.9.0"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/thisismairaj/az-brake.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/thisismairaj/az-brake#readme",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/thisismairaj/az-brake/issues"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"claude-code",
|
|
32
|
+
"claude-code-plugin",
|
|
33
|
+
"azure",
|
|
34
|
+
"azure-cli",
|
|
35
|
+
"finops",
|
|
36
|
+
"cost-control",
|
|
37
|
+
"hook"
|
|
38
|
+
],
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
".claude-plugin",
|
|
42
|
+
"hooks",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
]
|
|
46
|
+
}
|