troxy-cli 1.29.15 → 1.29.16
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/bin/troxy.js +7 -0
- package/package.json +10 -2
- package/src/action-classification.js +195 -0
- package/src/api.js +12 -7
- package/src/policies.js +34 -34
- package/src/pretooluse-hook.js +88 -39
- package/src/proxy.js +8 -6
- package/src/tests/action-classification.test.js +152 -0
- package/src/tests/policies-org.test.js +12 -11
- package/src/tests/proxy-wiring.test.js +7 -5
package/bin/troxy.js
CHANGED
|
@@ -704,6 +704,13 @@ switch (command) {
|
|
|
704
704
|
troxy status API health + MCP status (no login needed)
|
|
705
705
|
troxy update Update to the latest version
|
|
706
706
|
|
|
707
|
+
Proxy (experimental - routes Claude's own model calls through Troxy for
|
|
708
|
+
real-time policy enforcement and cost optimization, separate from the MCP
|
|
709
|
+
connection above)
|
|
710
|
+
troxy proxy status Show whether it's on, and cert/interceptor health
|
|
711
|
+
troxy proxy enable --experimental Turn it on (installs a local CA cert)
|
|
712
|
+
troxy proxy disable Turn it off - removes the settings.json patch
|
|
713
|
+
|
|
707
714
|
Everything else requires: troxy login
|
|
708
715
|
|
|
709
716
|
MCP
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.16",
|
|
4
4
|
"description": "A secure control layer for AI agents: policies across payments, messages, logins, destructive actions, model usage, and secrets, all enforceable from the CLI",
|
|
5
5
|
"homepage": "https://troxy.io",
|
|
6
6
|
"bugs": {
|
|
@@ -14,9 +14,17 @@
|
|
|
14
14
|
"test": "node --test src/tests/*.test.js"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
17
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
18
18
|
"node-forge": "^1.4.0"
|
|
19
19
|
},
|
|
20
|
+
"overrides": {
|
|
21
|
+
"fast-uri": "^3.1.6",
|
|
22
|
+
"ip-address": "^10.3.1",
|
|
23
|
+
"hono": "^4.13.8",
|
|
24
|
+
"qs": "^6.16.0",
|
|
25
|
+
"body-parser": "^2.3.0",
|
|
26
|
+
"@hono/node-server": "^1.19.17"
|
|
27
|
+
},
|
|
20
28
|
"engines": {
|
|
21
29
|
"node": ">=18"
|
|
22
30
|
},
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Turns a Bash tool_input about to run into the same {action_verb, resource,
|
|
2
|
+
// item_count} shape the evaluate_action MCP tool asks agents to self-report
|
|
3
|
+
// (see mcp-server.js's evaluate_action inputSchema) - so the PreToolUse hook
|
|
4
|
+
// can ask on the agent's behalf instead of trusting it to call the tool
|
|
5
|
+
// first. That trust was the exact gap evaluate_model already proved doesn't
|
|
6
|
+
// hold (see pretooluse-hook.js's own docstring): a real session ran a
|
|
7
|
+
// destructive command directly and never checked in.
|
|
8
|
+
//
|
|
9
|
+
// Regex over shell segments, not a real shell parser - same tradeoff
|
|
10
|
+
// command-compaction.js makes, but the opposite risk direction. There, a
|
|
11
|
+
// wrong match rewrites the user's actual command, so the rules are narrow
|
|
12
|
+
// and a miss is harmless. Here, a match only ever ADDS one evaluate_action
|
|
13
|
+
// round-trip inside the hook's existing fail-open budget - the backend
|
|
14
|
+
// still makes the real ALLOW/BLOCK call, so a false match costs latency,
|
|
15
|
+
// nothing worse. A MISS, on the other hand, silently falls back to the
|
|
16
|
+
// pre-existing advisory MCP path (unchanged, not made worse). So unlike
|
|
17
|
+
// command-compaction.js, it's fine - correct, even - for these rules to
|
|
18
|
+
// err a little broad rather than a little narrow.
|
|
19
|
+
//
|
|
20
|
+
// Deliberately not exhaustive: this covers the highest-confidence,
|
|
21
|
+
// highest-blast-radius shapes (recursive/plain deletes, force-pushes,
|
|
22
|
+
// branch deletes, infra destroys, DB drops/truncates) rather than trying to
|
|
23
|
+
// pattern-match every verb evaluate_action's schema lists (share, upload,
|
|
24
|
+
// export, revoke are too shape-varied to recognize reliably from a raw
|
|
25
|
+
// command string without a much higher false-positive rate). Extend this
|
|
26
|
+
// list as real gaps show up, not preemptively.
|
|
27
|
+
//
|
|
28
|
+
// item_count is only ever a real count (e.g. the number of path arguments
|
|
29
|
+
// to `rm`) or the tool's own documented default of 1 - never a guess at
|
|
30
|
+
// how many rows/records/files a command will actually touch, since this
|
|
31
|
+
// module does no I/O (no listing directories, no querying the resource) by
|
|
32
|
+
// design: it runs synchronously, inline, on every single tool call.
|
|
33
|
+
//
|
|
34
|
+
// reversible is never set here, only ever omitted. evaluate_action's own
|
|
35
|
+
// contract treats an undeclared reversibility as irreversible so a guess
|
|
36
|
+
// can't wave something through - a heuristic classifier has even less
|
|
37
|
+
// business claiming reversibility than the agent itself does, so it never
|
|
38
|
+
// tries.
|
|
39
|
+
|
|
40
|
+
function segments(command) {
|
|
41
|
+
// Split on top-level &&, ||, |, ;, and newlines - but not inside a single
|
|
42
|
+
// or double-quoted string, so `echo "rm -rf /" && ls` doesn't let the
|
|
43
|
+
// quoted text leak a false match into its own segment. Doesn't handle
|
|
44
|
+
// nested $(...) or backticks; a command hidden inside one of those is a
|
|
45
|
+
// known miss, not a wrong-direction failure (see module docstring).
|
|
46
|
+
const parts = [];
|
|
47
|
+
let current = '';
|
|
48
|
+
let quote = null;
|
|
49
|
+
for (const ch of command) {
|
|
50
|
+
if (quote) {
|
|
51
|
+
current += ch;
|
|
52
|
+
if (ch === quote) quote = null;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (ch === '"' || ch === "'") {
|
|
56
|
+
quote = ch;
|
|
57
|
+
current += ch;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (ch === '&' || ch === '|' || ch === ';' || ch === '\n') {
|
|
61
|
+
parts.push(current);
|
|
62
|
+
current = '';
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
current += ch;
|
|
66
|
+
}
|
|
67
|
+
parts.push(current);
|
|
68
|
+
return parts.map((s) => s.trim()).filter(Boolean);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Non-flag tokens after a leading command name - `rm -rf a.txt b.txt` -> ['a.txt', 'b.txt'].
|
|
72
|
+
function pathArgs(segment, afterWords) {
|
|
73
|
+
const words = segment.split(/\s+/).slice(afterWords);
|
|
74
|
+
return words.filter((w) => w && !w.startsWith('-'));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function summarizeResource(paths, fallback) {
|
|
78
|
+
if (!paths.length) return fallback;
|
|
79
|
+
const shown = paths.slice(0, 3).join(', ');
|
|
80
|
+
return paths.length > 3 ? `${shown} (+${paths.length - 3} more)` : shown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const DB_CLI = /^(psql|mysql|mariadb|sqlite3|mongosh?|redis-cli)\b/i;
|
|
84
|
+
|
|
85
|
+
// Each rule: test a single shell segment, return {action_verb, resource,
|
|
86
|
+
// item_count} or null. First match wins - one flagged segment in a compound
|
|
87
|
+
// command is enough to trigger a check, see module docstring.
|
|
88
|
+
const RULES = [
|
|
89
|
+
// rm: any invocation with at least one non-flag argument. Not narrowed to
|
|
90
|
+
// -rf on purpose - `rm one-file.txt` is still a real, irreversible delete.
|
|
91
|
+
(segment) => {
|
|
92
|
+
if (!/^rm\s+\S/.test(segment)) return null;
|
|
93
|
+
const paths = pathArgs(segment, 1);
|
|
94
|
+
return {
|
|
95
|
+
action_verb: 'delete',
|
|
96
|
+
resource: summarizeResource(paths, 'file(s) via rm'),
|
|
97
|
+
item_count: paths.length || 1,
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
// git push --force / -f / --force-with-lease
|
|
102
|
+
(segment) => {
|
|
103
|
+
if (!/^git\s+push\b/.test(segment)) return null;
|
|
104
|
+
if (!/(\s--force(-with-lease)?\b|\s-f\b)/.test(segment)) return null;
|
|
105
|
+
const paths = pathArgs(segment, 2).filter((w) => !w.startsWith('--'));
|
|
106
|
+
return {
|
|
107
|
+
action_verb: 'force_push',
|
|
108
|
+
resource: summarizeResource(paths, 'the remote branch'),
|
|
109
|
+
item_count: 1,
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
// git branch -D, or git push <remote> --delete <branch> / :<branch>
|
|
114
|
+
(segment) => {
|
|
115
|
+
if (/^git\s+branch\b.*\s-D\b/.test(segment)) {
|
|
116
|
+
const paths = pathArgs(segment, 2).filter((w) => !w.startsWith('-'));
|
|
117
|
+
return { action_verb: 'delete', resource: summarizeResource(paths, 'a branch'), item_count: 1 };
|
|
118
|
+
}
|
|
119
|
+
if (/^git\s+push\b/.test(segment) && (/\s--delete\b/.test(segment) || /\s\S*:\S+/.test(segment))) {
|
|
120
|
+
// `git push origin :branch` (empty source = delete) and
|
|
121
|
+
// `git push origin local:branch` both use the same refspec shape;
|
|
122
|
+
// either way the part after the colon is the remote branch being
|
|
123
|
+
// touched, which is what matters here regardless of which it deletes.
|
|
124
|
+
const target = segment.match(/--delete\s+(\S+)/)?.[1] || segment.match(/\s\S*:(\S+)/)?.[1];
|
|
125
|
+
return { action_verb: 'delete', resource: target || 'a remote branch', item_count: 1 };
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// terraform destroy
|
|
131
|
+
(segment) => {
|
|
132
|
+
if (!/^terraform\s+destroy\b/.test(segment)) return null;
|
|
133
|
+
const target = segment.match(/-target[= ]("[^"]+"|\S+)/)?.[1];
|
|
134
|
+
return { action_verb: 'destroy', resource: target || 'infrastructure (terraform)', item_count: 1 };
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
// kubectl delete
|
|
138
|
+
(segment) => {
|
|
139
|
+
if (!/^kubectl\s+delete\b/.test(segment)) return null;
|
|
140
|
+
const paths = pathArgs(segment, 2);
|
|
141
|
+
return { action_verb: 'delete', resource: summarizeResource(paths, 'a cluster resource'), item_count: paths.length || 1 };
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
// docker rm / rmi / volume rm / system prune
|
|
145
|
+
(segment) => {
|
|
146
|
+
if (!/^docker\s+(rm|rmi)\b/.test(segment) && !/^docker\s+volume\s+rm\b/.test(segment) && !/^docker\s+system\s+prune\b/.test(segment)) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const paths = pathArgs(segment, segment.startsWith('docker volume') ? 3 : 2);
|
|
150
|
+
return { action_verb: 'delete', resource: summarizeResource(paths, 'docker resource(s)'), item_count: paths.length || 1 };
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
// aws s3 rm
|
|
154
|
+
(segment) => {
|
|
155
|
+
if (!/^aws\s+s3\s+rm\b/.test(segment)) return null;
|
|
156
|
+
const paths = pathArgs(segment, 3);
|
|
157
|
+
return { action_verb: 'delete', resource: summarizeResource(paths, 'an s3 path'), item_count: 1 };
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
// DB DROP TABLE/DATABASE/SCHEMA - only when the segment is actually
|
|
161
|
+
// invoking a known DB CLI, to avoid matching the words inside unrelated
|
|
162
|
+
// text (a commit message, a comment, an echoed string).
|
|
163
|
+
(segment) => {
|
|
164
|
+
if (!DB_CLI.test(segment)) return null;
|
|
165
|
+
if (/\bdrop\s+(table|database|schema)\b/i.test(segment)) {
|
|
166
|
+
const resource = segment.match(/\bdrop\s+(?:table|database|schema)\s+([^\s"')]+)/i)?.[1];
|
|
167
|
+
return { action_verb: 'drop', resource: resource || 'a database object', item_count: 1 };
|
|
168
|
+
}
|
|
169
|
+
if (/\btruncate\s+table\b/i.test(segment)) {
|
|
170
|
+
const resource = segment.match(/\btruncate\s+table\s+([^\s"')]+)/i)?.[1];
|
|
171
|
+
return { action_verb: 'truncate', resource: resource || 'a table', item_count: 1 };
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
// Returns {action_verb, resource, item_count} for the first recognized
|
|
178
|
+
// destructive shape found anywhere in the command, or null. Only Bash is
|
|
179
|
+
// handled for now - Write/Edit overwriting an existing file is a much
|
|
180
|
+
// noisier signal (nearly every edit would match) and usually git-recoverable,
|
|
181
|
+
// so it's deliberately left to the advisory evaluate_action path rather than
|
|
182
|
+
// flagged here.
|
|
183
|
+
export function actionFor(toolName, toolInput) {
|
|
184
|
+
if (toolName !== 'Bash') return null;
|
|
185
|
+
const command = toolInput?.command;
|
|
186
|
+
if (!command || typeof command !== 'string') return null;
|
|
187
|
+
|
|
188
|
+
for (const segment of segments(command)) {
|
|
189
|
+
for (const rule of RULES) {
|
|
190
|
+
const match = rule(segment);
|
|
191
|
+
if (match) return match;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
package/src/api.js
CHANGED
|
@@ -68,13 +68,18 @@ export const api = {
|
|
|
68
68
|
// use, so a condition that previews as firing is trusted, not guessed.
|
|
69
69
|
previewPolicy: (jwt, b) => request('POST', '/dashboard/policies/preview', { jwt, body: b }),
|
|
70
70
|
|
|
71
|
-
// Org (admin can write, any active member can read - see
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
// Org-scoped policies (admin can write, any active member can read - see
|
|
72
|
+
// workspaces.py's is_workspace_admin vs is_workspace_member split).
|
|
73
|
+
// "Org" here is the policy scope tier (org-wide vs space-scoped), not the
|
|
74
|
+
// tenant - kept as-is to match the backend's policies.scope column and
|
|
75
|
+
// the dashboard's --org flag naming. workspaceId comes from
|
|
76
|
+
// currentWorkspace() and is the tenant's id, passed in to build these
|
|
77
|
+
// org-scoped-policy URLs.
|
|
78
|
+
currentWorkspace: (jwt) => request('GET', '/workspaces/current', { jwt }),
|
|
79
|
+
listOrgPolicies: (jwt, workspaceId) => request('GET', `/workspaces/${workspaceId}/policies`, { jwt }),
|
|
80
|
+
createOrgPolicy: (jwt, workspaceId, b) => request('POST', `/workspaces/${workspaceId}/policies`, { jwt, body: b }),
|
|
81
|
+
updateOrgPolicy: (jwt, workspaceId, id, b) => request('PATCH', `/workspaces/${workspaceId}/policies/${id}`, { jwt, body: b }),
|
|
82
|
+
deleteOrgPolicy: (jwt, workspaceId, id) => request('DELETE', `/workspaces/${workspaceId}/policies/${id}`, { jwt }),
|
|
78
83
|
|
|
79
84
|
// Activity + insights
|
|
80
85
|
activity: (jwt, limit) => request('GET', `/dashboard/activity?limit=${limit || 20}`, { jwt }),
|
package/src/policies.js
CHANGED
|
@@ -30,7 +30,7 @@ function _promptYN(question) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const HELP = {
|
|
33
|
-
list: ` troxy policies list [--org]\n\n Lists every org and space policy currently in force for you. --org narrows this to just your
|
|
33
|
+
list: ` troxy policies list [--org]\n\n Lists every org and space policy currently in force for you. --org narrows this to just your workspace's policies (any member can read them, admin-authored).\n`,
|
|
34
34
|
describe: ` troxy policies describe --name <policy-name> [--org]\n\n Shows full details for a single policy.\n\n Options:\n --name Name of the policy (use single quotes for names with special chars)\n --org Look up an org policy specifically, instead of the combined org+space view\n`,
|
|
35
35
|
create: ` troxy policies create --name <name> --action <action> --domain <domain> [options]\n troxy policies create --name <name> --action <action> --template "<template label>"\n troxy policies create --describe "<plain English>"\n\n Creates a new policy. Login required. Policy creation is admin-only and\n always org-wide - pass --org (admin access required). Personal-scope and\n per-MCP policies no longer exist; space-scoped creation isn't available\n from the CLI yet, use the dashboard for that.\n\n Checkpoints (--domain), one per Troxy checkpoint - what the policy is about:\n payment paying, buying, sending money (/evaluate)\n comms messages on any channel (email, Slack...) (/evaluate/email)\n access logging in or signing up on a site (/evaluate/login)\n destructive deletes, exports, permission/access changes, other consequential actions (/evaluate/action)\n model the agent's own model, tokens, cost (/evaluate/model)\n secrets content scanned for secrets before it leaves (/evaluate/secrets)\n\n Run 'troxy policies fields --domain <domain>' to see the exact fields and\n operators each checkpoint supports - the same table the server checks\n against, so nothing here can drift from what actually fires.\n\n From a template (fastest, matches the dashboard's own template library):\n --template "<label>" Exact template label - run 'troxy policies templates' to list them\n --value / --value2 Override the template's default threshold (only when it has exactly one condition)\n --action Override the template's default action\n\n Manual, single condition:\n --domain Required whenever --field is given, unless --field is one of\n the original payment fields (amount, merchant_name, tx_per_day),\n which still default to --domain payment for old scripts.\n --field Field to match - see 'troxy policies fields --domain <domain>'\n --operator eq, neq, gt, gte, lt, lte, contains, starts_with, not_contains, between\n --value Comparison value\n --value2 Upper bound for 'between'\n\n Manual, multiple conditions:\n --and "field=X,operator=Y,value=Z" Repeatable. ANDs onto the single --field condition (if any).\n --or "field=X,operator=Y,value=Z" Repeatable. Each becomes its own alternative branch (any\n --and conditions apply to every branch); all branches share --action.\n\n Raw (full expressiveness, for scripting):\n --json '<JSON>' {\"conditions\": [...], \"or_conditions\": [...]} exactly as the API expects.\n Cannot be combined with --field/--and/--or/--template.\n\n AI builder (org AI key, admin required; --domain payment or comms only - the other four\n checkpoints aren't covered by this endpoint yet, use --template or a manual condition):\n --describe "<text>" Describe the policy in plain English and Troxy AI drafts it for you to confirm.\n --domain payment (default) or comms - which allowlist the AI drafts against.\n\n Priority:\n --priority <n> Explicit priority number (default: auto, max+10). Lower = evaluated first.\n\n Org (required; applies to every member):\n --org Admin access required. Every policy created from the CLI is org-wide.\n\n Examples:\n troxy policies create --name "Block a secret going public" --template "Block a secret going somewhere public" --org\n troxy policies create --name "Cap over 100" --action BLOCK --domain payment --field amount --operator gt --value 100 --org\n troxy policies create --name "Signups" --action ESCALATE --domain access --field login_action --operator eq --value signup --org\n troxy policies create --name "Big exports" --action BLOCK --domain destructive --field action_verb --operator eq --value export --and "field=resource,operator=contains,value=customer" --org\n troxy policies create --name "Personal domains" --action BLOCK --domain comms --or "field=recipient_domain,operator=contains,value=gmail.com" --or "field=recipient_domain,operator=contains,value=yahoo.com" --org\n troxy policies create --name "Lock down secrets" --action BLOCK --domain secrets --field has_api_key --operator eq --value true --org\n`,
|
|
36
36
|
fields: ` troxy policies fields [--domain <domain>]\n\n Lists the fields, operators and allowed values each checkpoint actually supports -\n the same table the server validates against, so this can never offer something\n that saves and then never fires. Omit --domain to see all six.\n`,
|
|
@@ -60,10 +60,10 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
|
|
|
60
60
|
switch (sub || 'list') {
|
|
61
61
|
case 'list': {
|
|
62
62
|
if (flags.org) {
|
|
63
|
-
const
|
|
64
|
-
const { policies = [] } = await api.listOrgPolicies(jwt,
|
|
65
|
-
if (!policies.length) { console.log(`\n No org policies yet in ${
|
|
66
|
-
console.log(`\n Organization: ${
|
|
63
|
+
const workspace = await _resolveOrgContext(jwt, false);
|
|
64
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, workspace.id);
|
|
65
|
+
if (!policies.length) { console.log(`\n No org policies yet in ${workspace.name}.\n`); return; }
|
|
66
|
+
console.log(`\n Organization: ${workspace.name}`);
|
|
67
67
|
table(
|
|
68
68
|
['#', 'Name', 'Domain', 'Action', 'Status', 'Conditions'],
|
|
69
69
|
policies.map(p => [p.priority, p.name, p.domain, _displayAction(p), p.enabled ? 'active' : 'paused', _condSummary(p)]),
|
|
@@ -93,17 +93,17 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
|
|
|
93
93
|
const name = flags.name;
|
|
94
94
|
if (!name) { console.error(' --name is required (tip: use single quotes for names with special chars)\n'); process.exit(1); }
|
|
95
95
|
if (flags.org) {
|
|
96
|
-
const
|
|
97
|
-
const { policies = [] } = await api.listOrgPolicies(jwt,
|
|
96
|
+
const workspace = await _resolveOrgContext(jwt, false);
|
|
97
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, workspace.id);
|
|
98
98
|
const p = policies.find(x => x.name.toLowerCase() === name.toLowerCase());
|
|
99
|
-
if (!p) { console.error(` Org policy "${name}" not found in ${
|
|
99
|
+
if (!p) { console.error(` Org policy "${name}" not found in ${workspace.name}\n`); process.exit(1); }
|
|
100
100
|
console.log(`
|
|
101
101
|
Name: ${p.name}
|
|
102
102
|
Domain: ${p.domain}
|
|
103
103
|
Action: ${_displayAction(p)}
|
|
104
104
|
Priority: ${p.priority}
|
|
105
105
|
Status: ${p.enabled ? 'active' : 'paused'}
|
|
106
|
-
Scope: org-wide (${
|
|
106
|
+
Scope: org-wide (${workspace.name})
|
|
107
107
|
Conditions: ${_condDetail(p)}
|
|
108
108
|
Created: ${new Date(p.created_at).toLocaleDateString()}
|
|
109
109
|
`);
|
|
@@ -162,7 +162,7 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
|
|
|
162
162
|
// Personal-scope and per-MCP policy creation were eliminated (product
|
|
163
163
|
// decision, 2026-08-20): POST /dashboard/policies no longer exists on
|
|
164
164
|
// the server at all (falls through to a bare 404), so every policy the
|
|
165
|
-
// CLI creates now has to go through /
|
|
165
|
+
// CLI creates now has to go through /workspaces/:id/policies instead. --org
|
|
166
166
|
// is required up front, before touching --describe/--template/manual
|
|
167
167
|
// condition parsing, so a user who forgets it gets this clear message
|
|
168
168
|
// instead of the server's generic "not found".
|
|
@@ -184,7 +184,7 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
|
|
|
184
184
|
console.error(`\n --describe only supports --domain payment or comms (the AI builder does not cover the other checkpoints yet - try --template or build the condition manually instead).\n`);
|
|
185
185
|
process.exit(1);
|
|
186
186
|
}
|
|
187
|
-
const
|
|
187
|
+
const workspace = await _resolveOrgContext(jwt, true);
|
|
188
188
|
process.stdout.write('\n Asking Troxy AI to build this policy... ');
|
|
189
189
|
const draft = await api.aiPolicy(jwt, { text: flags.describe, is_email: describeDomain === 'comms' });
|
|
190
190
|
if (draft.error) { console.log('✗'); console.error(`\n ${draft.error}\n`); process.exit(1); }
|
|
@@ -199,8 +199,8 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
|
|
|
199
199
|
const name = draft.name, action = draft.action, domain = describeDomain,
|
|
200
200
|
conditions = draft.conditions || [], or_conditions = draft.or_conditions || [];
|
|
201
201
|
const body = { name, action, domain, conditions, or_conditions };
|
|
202
|
-
const policy = await api.createOrgPolicy(jwt,
|
|
203
|
-
console.log(`\n Org policy "${policy.name}" created ✓ (priority: ${policy.priority}, applies to every member of ${
|
|
202
|
+
const policy = await api.createOrgPolicy(jwt, workspace.id, body);
|
|
203
|
+
console.log(`\n Org policy "${policy.name}" created ✓ (priority: ${policy.priority}, applies to every member of ${workspace.name})\n`);
|
|
204
204
|
break;
|
|
205
205
|
}
|
|
206
206
|
|
|
@@ -209,13 +209,13 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
|
|
|
209
209
|
|
|
210
210
|
const { domain, action, conditions, or_conditions } = _buildConditionsFromFlags(flags, multiFlags, { requireAction: true });
|
|
211
211
|
|
|
212
|
-
const
|
|
212
|
+
const workspace = await _resolveOrgContext(jwt, true);
|
|
213
213
|
const priority = flags.priority != null ? parseInt(flags.priority, 10) : undefined;
|
|
214
214
|
if (flags.priority != null && isNaN(priority)) { console.error(' --priority must be a number\n'); process.exit(1); }
|
|
215
215
|
const body = { name, action, domain, conditions, or_conditions, enabled: true };
|
|
216
216
|
if (priority != null) body.priority = priority;
|
|
217
|
-
const policy = await api.createOrgPolicy(jwt,
|
|
218
|
-
console.log(`\n Org policy "${policy.name}" created ✓ (priority: ${policy.priority}, checkpoint: ${domain}, applies to every member of ${
|
|
217
|
+
const policy = await api.createOrgPolicy(jwt, workspace.id, body);
|
|
218
|
+
console.log(`\n Org policy "${policy.name}" created ✓ (priority: ${policy.priority}, checkpoint: ${domain}, applies to every member of ${workspace.name})\n`);
|
|
219
219
|
break;
|
|
220
220
|
}
|
|
221
221
|
|
|
@@ -223,11 +223,11 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
|
|
|
223
223
|
const name = flags.name;
|
|
224
224
|
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
225
225
|
if (flags.org) {
|
|
226
|
-
const
|
|
227
|
-
const { policies = [] } = await api.listOrgPolicies(jwt,
|
|
226
|
+
const workspace = await _resolveOrgContext(jwt, true);
|
|
227
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, workspace.id);
|
|
228
228
|
const policy = policies.find(p => p.name === name);
|
|
229
|
-
if (!policy) { console.error(` Org policy "${name}" not found in ${
|
|
230
|
-
await api.deleteOrgPolicy(jwt,
|
|
229
|
+
if (!policy) { console.error(` Org policy "${name}" not found in ${workspace.name}\n`); process.exit(1); }
|
|
230
|
+
await api.deleteOrgPolicy(jwt, workspace.id, policy.id);
|
|
231
231
|
console.log(`\n Org policy "${name}" deleted ✓\n`);
|
|
232
232
|
break;
|
|
233
233
|
}
|
|
@@ -251,11 +251,11 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
|
|
|
251
251
|
const name = flags.name;
|
|
252
252
|
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
253
253
|
if (flags.org) {
|
|
254
|
-
const
|
|
255
|
-
const { policies = [] } = await api.listOrgPolicies(jwt,
|
|
254
|
+
const workspace = await _resolveOrgContext(jwt, true);
|
|
255
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, workspace.id);
|
|
256
256
|
const policy = policies.find(p => p.name === name);
|
|
257
|
-
if (!policy) { console.error(` Org policy "${name}" not found in ${
|
|
258
|
-
await api.updateOrgPolicy(jwt,
|
|
257
|
+
if (!policy) { console.error(` Org policy "${name}" not found in ${workspace.name}\n`); process.exit(1); }
|
|
258
|
+
await api.updateOrgPolicy(jwt, workspace.id, policy.id, { enabled: sub === 'resume' });
|
|
259
259
|
console.log(`\n Org policy "${name}" ${sub}d ✓\n`);
|
|
260
260
|
break;
|
|
261
261
|
}
|
|
@@ -283,11 +283,11 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
|
|
|
283
283
|
const priority = parseInt(flags.priority, 10);
|
|
284
284
|
if (isNaN(priority)) { console.error(' --priority must be a number\n'); process.exit(1); }
|
|
285
285
|
if (flags.org) {
|
|
286
|
-
const
|
|
287
|
-
const { policies = [] } = await api.listOrgPolicies(jwt,
|
|
286
|
+
const workspace = await _resolveOrgContext(jwt, true);
|
|
287
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, workspace.id);
|
|
288
288
|
const policy = policies.find(p => p.name === name);
|
|
289
|
-
if (!policy) { console.error(` Org policy "${name}" not found in ${
|
|
290
|
-
await api.updateOrgPolicy(jwt,
|
|
289
|
+
if (!policy) { console.error(` Org policy "${name}" not found in ${workspace.name}\n`); process.exit(1); }
|
|
290
|
+
await api.updateOrgPolicy(jwt, workspace.id, policy.id, { priority });
|
|
291
291
|
console.log(`\n Org policy "${name}" priority set to ${priority} ✓\n`);
|
|
292
292
|
break;
|
|
293
293
|
}
|
|
@@ -319,16 +319,16 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
|
|
|
319
319
|
// ── Org context ──────────────────────────────────────────────────────────
|
|
320
320
|
|
|
321
321
|
async function _resolveOrgContext(jwt, needAdmin) {
|
|
322
|
-
const {
|
|
323
|
-
if (!
|
|
324
|
-
console.error('\n --org was passed, but you are not in
|
|
322
|
+
const { workspace } = await api.currentWorkspace(jwt);
|
|
323
|
+
if (!workspace) {
|
|
324
|
+
console.error('\n --org was passed, but you are not in a workspace.\n');
|
|
325
325
|
process.exit(1);
|
|
326
326
|
}
|
|
327
|
-
if (needAdmin &&
|
|
328
|
-
console.error(`\n Org policies require admin access. You are a '${
|
|
327
|
+
if (needAdmin && workspace.role !== 'admin') {
|
|
328
|
+
console.error(`\n Org policies require admin access. You are a '${workspace.role}' in ${workspace.name}.\n`);
|
|
329
329
|
process.exit(1);
|
|
330
330
|
}
|
|
331
|
-
return
|
|
331
|
+
return workspace;
|
|
332
332
|
}
|
|
333
333
|
|
|
334
334
|
// ── Condition building, shared by create and test ──────────────────────────
|
package/src/pretooluse-hook.js
CHANGED
|
@@ -2,6 +2,7 @@ import { loadConfig } from './config.js';
|
|
|
2
2
|
import { api } from './api.js';
|
|
3
3
|
import { usageForCurrentTurn } from './hook-report.js';
|
|
4
4
|
import { updatedInputFor } from './command-compaction.js';
|
|
5
|
+
import { actionFor } from './action-classification.js';
|
|
5
6
|
|
|
6
7
|
// Claude Code PreToolUse hook entry point: `troxy pretooluse-hook` (wired in
|
|
7
8
|
// bin/troxy.js, registered by patchClaudeCodeHooks in init.js). Fires before
|
|
@@ -11,13 +12,18 @@ import { updatedInputFor } from './command-compaction.js';
|
|
|
11
12
|
// bearing here: a bug that hangs or wrongly denies would degrade every tool
|
|
12
13
|
// call in every project, not just Token Optimization data.
|
|
13
14
|
//
|
|
14
|
-
// Why this exists: a
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// just used its own Bash tool directly, never checked in, even on a
|
|
18
|
-
// session with a valid key). PreToolUse is the host's own gate, not the
|
|
19
|
-
// agent's discretion, so it's the
|
|
20
|
-
//
|
|
15
|
+
// Why this exists: a policy was previously only enforceable if the connected
|
|
16
|
+
// agent voluntarily called the matching evaluate_* MCP tool before acting on
|
|
17
|
+
// its own - proven live to not happen for model policy (a real Claude Code
|
|
18
|
+
// session just used its own Bash tool directly, never checked in, even on a
|
|
19
|
+
// fresh session with a valid key). PreToolUse is the host's own gate, not the
|
|
20
|
+
// agent's discretion, so it's the mechanism that actually enforces a policy
|
|
21
|
+
// instead of hoping the agent asks first. It now covers two independent
|
|
22
|
+
// domains this way: model policy (evaluate_model, since the beginning of
|
|
23
|
+
// this file) and destructive/hard-to-undo Bash commands (evaluate_action,
|
|
24
|
+
// via action-classification.js's heuristic match). Payment, login, and
|
|
25
|
+
// secrets policies still only have the advisory MCP path - the same gap
|
|
26
|
+
// this file exists to close, not yet closed for those three.
|
|
21
27
|
//
|
|
22
28
|
// Everything in this file must fail open. A broken or slow check must never
|
|
23
29
|
// block a tool call that a real policy would have allowed anyway - see the
|
|
@@ -106,51 +112,94 @@ export async function runPreToolUseHook() {
|
|
|
106
112
|
// behind the same fail-open checks the policy path needs.
|
|
107
113
|
const rewrite = updatedInputFor(payload?.tool_name, payload?.tool_input);
|
|
108
114
|
|
|
109
|
-
|
|
110
|
-
|
|
115
|
+
// Also independent, also pure/local/synchronous: does this specific
|
|
116
|
+
// tool call look like a destructive or hard-to-undo action? See
|
|
117
|
+
// action-classification.js for exactly what this does and doesn't
|
|
118
|
+
// recognize, and why. Unlike the model check below, this needs no
|
|
119
|
+
// transcript at all - an `rm -rf` is worth gating whether or not a
|
|
120
|
+
// model policy even applies to this turn.
|
|
121
|
+
const actionMatch = actionFor(payload?.tool_name, payload?.tool_input);
|
|
111
122
|
|
|
123
|
+
const transcriptPath = payload?.transcript_path;
|
|
112
124
|
// transcript_path can lag the current turn by a beat (Claude Code's own
|
|
113
125
|
// docs note this), so this may occasionally read the previous turn's
|
|
114
126
|
// model instead of one just switched to mid-conversation. Worst case:
|
|
115
127
|
// one tool call briefly evaluated against the wrong (but recent) model -
|
|
116
128
|
// not a wrong-direction failure (never wrongly denies because of it),
|
|
117
129
|
// just an occasional miss, consistent with fail-open throughout this file.
|
|
118
|
-
const usage = usageForCurrentTurn(transcriptPath);
|
|
119
|
-
|
|
130
|
+
const usage = transcriptPath ? usageForCurrentTurn(transcriptPath) : null;
|
|
131
|
+
|
|
132
|
+
if (!usage?.model && !actionMatch) return allow(rewrite); // nothing to gate against
|
|
120
133
|
|
|
121
134
|
const config = loadConfig();
|
|
122
135
|
const apiKey = process.env.TROXY_API_KEY || config?.apiKey;
|
|
123
136
|
if (!apiKey) return allow(rewrite);
|
|
124
137
|
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
138
|
+
// Both checks below run concurrently, sharing one GATE_TIMEOUT_MS
|
|
139
|
+
// budget each - not one after the other. A tool call that happens to
|
|
140
|
+
// trip both (a destructive command on a turn that also needs a model
|
|
141
|
+
// check) must not cost double the latency of a call that only trips
|
|
142
|
+
// one; Claude Code's own hook timeout is a single fixed budget for the
|
|
143
|
+
// whole process, not per-check.
|
|
144
|
+
const checks = [];
|
|
145
|
+
|
|
146
|
+
if (usage?.model) {
|
|
147
|
+
const body = { model: usage.model, task: payload.tool_name || '' };
|
|
148
|
+
// Real effort straight off the transcript entry (see hook-report.js's
|
|
149
|
+
// extractUsage) - whatever Claude Code actually used, not coerced into
|
|
150
|
+
// a guessed low/medium/high. Omitted when the transcript doesn't carry
|
|
151
|
+
// one (an older Claude Code build, or a turn before any assistant
|
|
152
|
+
// reply yet).
|
|
153
|
+
if (usage.effort) body.effort = usage.effort;
|
|
154
|
+
// How many tools this turn has used so far (from the transcript), plus
|
|
155
|
+
// the one about to run that triggered this hook. An undercount by
|
|
156
|
+
// nature - a PreToolUse hook cannot know how many MORE tool calls this
|
|
157
|
+
// turn will end up making - but a real, current-as-of-right-now number,
|
|
158
|
+
// not a guess, and the effort-suggestion feature only needs to tell a
|
|
159
|
+
// turn's first tool call apart from its fifth, not know the eventual
|
|
160
|
+
// total. Without this, effort was accepted but never actually
|
|
161
|
+
// classified as "simple", so no suggestion could ever fire from here.
|
|
162
|
+
body.estimated_tool_count = usage.toolUseBlocksSoFar + 1;
|
|
163
|
+
|
|
164
|
+
checks.push(
|
|
165
|
+
withTimeout(api.evaluateModel(body, apiKey), GATE_TIMEOUT_MS)
|
|
166
|
+
.then((result) => ({
|
|
167
|
+
decision: result?.decision,
|
|
168
|
+
reason: `Troxy: blocked by policy - ${result?.reason || `the model "${usage.model}" is not allowed.`}`,
|
|
169
|
+
}))
|
|
170
|
+
.catch(() => null), // this check's own failure must never suppress the other check's result
|
|
171
|
+
);
|
|
148
172
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
173
|
+
|
|
174
|
+
if (actionMatch) {
|
|
175
|
+
const body = { ...actionMatch };
|
|
176
|
+
// Same agent-identity convention evaluate_action's MCP tool handler
|
|
177
|
+
// uses (mcp-server.js), so the dashboard can tell a hook-enforced
|
|
178
|
+
// check apart from a voluntary self-reported one without a separate
|
|
179
|
+
// field - both are just "this agent called evaluate_action".
|
|
180
|
+
const agentName = process.env.TROXY_AGENT_NAME || config?.agentName;
|
|
181
|
+
if (agentName) body.agent = agentName;
|
|
182
|
+
|
|
183
|
+
checks.push(
|
|
184
|
+
withTimeout(api.evaluateAction(body, apiKey), GATE_TIMEOUT_MS)
|
|
185
|
+
.then((result) => ({
|
|
186
|
+
decision: result?.decision,
|
|
187
|
+
reason: `Troxy: blocked by policy - ${result?.reason || `this ${actionMatch.action_verb} is not allowed.`}`,
|
|
188
|
+
}))
|
|
189
|
+
.catch(() => null),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const settled = await Promise.all(checks);
|
|
194
|
+
const blocked = settled.find((entry) => entry?.decision === 'BLOCK');
|
|
195
|
+
if (blocked) return deny(blocked.reason);
|
|
196
|
+
|
|
197
|
+
// ALLOW, ESCALATE, a timeout/network error on either check, or anything
|
|
198
|
+
// unexpected: don't block. ESCALATE has no synchronous human-in-the-loop
|
|
199
|
+
// path from inside a hook (there is nowhere here to pause and wait on an
|
|
200
|
+
// approval), so both checks fail open rather than hanging Claude Code -
|
|
201
|
+
// same reasoning as every other "never block on our own uncertainty"
|
|
202
|
+
// branch in this file.
|
|
154
203
|
return allow(rewrite);
|
|
155
204
|
} catch {
|
|
156
205
|
// Network error, timeout, malformed response - never block the user's
|
package/src/proxy.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `troxy proxy status/enable/disable` - the explicit, hands-on control
|
|
3
3
|
* surface for Layer 3 of the Live Model Policy Enforcement plan (local
|
|
4
|
-
* interceptor set up in interceptor.js/tls-ca.js/daemon.js).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* interceptor set up in interceptor.js/tls-ca.js/daemon.js). `troxy init`
|
|
5
|
+
* (see init.js's own prompt around patchClaudeCodeInterception) already
|
|
6
|
+
* asks every new user this question directly and enables it the same way -
|
|
7
|
+
* this command is the secondary surface for turning it back on/off later
|
|
8
|
+
* without rerunning init, or checking cert/interceptor health. Listed in
|
|
9
|
+
* `troxy --help` for exactly that reason (2026-09-21): asked for by a real
|
|
10
|
+
* user who'd already had it enabled via init and could not find how to
|
|
11
|
+
* check or turn it off again.
|
|
10
12
|
*/
|
|
11
13
|
import net from 'node:net';
|
|
12
14
|
import os from 'node:os';
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Pure logic only - no hook plumbing here, see pretooluse-hook.test.js for
|
|
2
|
+
// how a match actually reaches the evaluate_action call.
|
|
3
|
+
|
|
4
|
+
import { describe, it } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
|
|
7
|
+
import { actionFor } from '../action-classification.js';
|
|
8
|
+
|
|
9
|
+
function bash(command) {
|
|
10
|
+
return actionFor('Bash', { command });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('actionFor - non-Bash / no match', () => {
|
|
14
|
+
it('ignores non-Bash tools entirely', () => {
|
|
15
|
+
assert.equal(actionFor('Write', { file_path: '/tmp/x', content: 'rm -rf /' }), null);
|
|
16
|
+
assert.equal(actionFor('Edit', { command: 'rm -rf /' }), null);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('ignores a missing or non-string command', () => {
|
|
20
|
+
assert.equal(actionFor('Bash', {}), null);
|
|
21
|
+
assert.equal(actionFor('Bash', { command: 123 }), null);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('does not match ordinary, non-destructive commands', () => {
|
|
25
|
+
assert.equal(bash('ls -la'), null);
|
|
26
|
+
assert.equal(bash('git status'), null);
|
|
27
|
+
assert.equal(bash('npm test'), null);
|
|
28
|
+
assert.equal(bash('echo "rm -rf my-code, wish me luck"'), null);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('actionFor - rm', () => {
|
|
33
|
+
it('matches a single-file rm and counts one item', () => {
|
|
34
|
+
const result = bash('rm notes.txt');
|
|
35
|
+
assert.equal(result.action_verb, 'delete');
|
|
36
|
+
assert.equal(result.item_count, 1);
|
|
37
|
+
assert.match(result.resource, /notes\.txt/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('matches recursive/force rm and counts the real number of paths', () => {
|
|
41
|
+
const result = bash('rm -rf build/ dist/ .cache/');
|
|
42
|
+
assert.equal(result.action_verb, 'delete');
|
|
43
|
+
assert.equal(result.item_count, 3);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('summarizes more than three paths with a remainder count', () => {
|
|
47
|
+
const result = bash('rm a.txt b.txt c.txt d.txt e.txt');
|
|
48
|
+
assert.equal(result.item_count, 5);
|
|
49
|
+
assert.match(result.resource, /\+2 more/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('does not match `rm` as a substring of another command name', () => {
|
|
53
|
+
assert.equal(bash('npm run rm-tmp'), null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('lets the docker-specific rule handle `docker rm`, not the generic rm rule', () => {
|
|
57
|
+
// Still ends up as a match (via the docker rule below) - just confirms
|
|
58
|
+
// the two rules don't double-fire or fight over the same segment.
|
|
59
|
+
assert.equal(bash('docker rm my-container').action_verb, 'delete');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('does not fire on a bare `rm` with no arguments', () => {
|
|
63
|
+
assert.equal(bash('rm'), null);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('actionFor - git force push / branch delete', () => {
|
|
68
|
+
it('matches --force and -f push, but not a plain push', () => {
|
|
69
|
+
assert.equal(bash('git push origin main'), null);
|
|
70
|
+
assert.equal(bash('git push --force origin main').action_verb, 'force_push');
|
|
71
|
+
assert.equal(bash('git push -f origin main').action_verb, 'force_push');
|
|
72
|
+
assert.equal(bash('git push --force-with-lease origin main').action_verb, 'force_push');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('matches git branch -D', () => {
|
|
76
|
+
const result = bash('git branch -D old-feature');
|
|
77
|
+
assert.equal(result.action_verb, 'delete');
|
|
78
|
+
assert.match(result.resource, /old-feature/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does not match the safer git branch -d', () => {
|
|
82
|
+
assert.equal(bash('git branch -d merged-feature'), null);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('matches remote branch delete via --delete and via the :branch refspec', () => {
|
|
86
|
+
assert.equal(bash('git push origin --delete old-feature').action_verb, 'delete');
|
|
87
|
+
assert.equal(bash('git push origin :old-feature').action_verb, 'delete');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('actionFor - terraform / kubectl / docker / aws', () => {
|
|
92
|
+
it('matches terraform destroy and pulls a -target when present', () => {
|
|
93
|
+
assert.equal(bash('terraform destroy').action_verb, 'destroy');
|
|
94
|
+
const result = bash('terraform destroy -target=aws_instance.web -auto-approve');
|
|
95
|
+
assert.equal(result.resource, 'aws_instance.web');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('matches kubectl delete', () => {
|
|
99
|
+
assert.equal(bash('kubectl delete deployment my-app').action_verb, 'delete');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('matches docker rm/rmi/volume rm/system prune', () => {
|
|
103
|
+
assert.equal(bash('docker rm my-container').action_verb, 'delete');
|
|
104
|
+
assert.equal(bash('docker rmi my-image').action_verb, 'delete');
|
|
105
|
+
assert.equal(bash('docker volume rm my-volume').action_verb, 'delete');
|
|
106
|
+
assert.equal(bash('docker system prune -af').action_verb, 'delete');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('does not match unrelated docker subcommands', () => {
|
|
110
|
+
assert.equal(bash('docker run my-image'), null);
|
|
111
|
+
assert.equal(bash('docker ps'), null);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('matches aws s3 rm', () => {
|
|
115
|
+
assert.equal(bash('aws s3 rm s3://my-bucket/path --recursive').action_verb, 'delete');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('actionFor - SQL drop/truncate, gated on a known DB CLI', () => {
|
|
120
|
+
it('matches DROP TABLE/DATABASE via a recognized DB client', () => {
|
|
121
|
+
assert.equal(bash('psql mydb -c "DROP TABLE users"').action_verb, 'drop');
|
|
122
|
+
assert.equal(bash('mysql -e "drop database staging"').action_verb, 'drop');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('matches TRUNCATE TABLE via a recognized DB client', () => {
|
|
126
|
+
const result = bash('psql mydb -c "TRUNCATE TABLE events"');
|
|
127
|
+
assert.equal(result.action_verb, 'truncate');
|
|
128
|
+
assert.equal(result.resource, 'events');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does not match DROP/TRUNCATE text outside of a known DB CLI invocation', () => {
|
|
132
|
+
// e.g. a commit message or comment mentioning the words - not an actual
|
|
133
|
+
// destructive command, and there is no DB client in this segment.
|
|
134
|
+
assert.equal(bash('git commit -m "drop table support from README"'), null);
|
|
135
|
+
assert.equal(bash('echo "remember to truncate table later"'), null);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('actionFor - compound commands', () => {
|
|
140
|
+
it('finds a destructive segment anywhere in a && chain', () => {
|
|
141
|
+
assert.equal(bash('cd /tmp && rm -rf scratch/').action_verb, 'delete');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('does not let a quoted string leak a match into an unrelated segment', () => {
|
|
145
|
+
assert.equal(bash('echo "rm -rf /" && ls'), null);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('reports never sets reversible - undeclared is treated as irreversible by policy', () => {
|
|
149
|
+
const result = bash('rm notes.txt');
|
|
150
|
+
assert.equal('reversible' in result, false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Org policy support end to end: api.js has the routes, policies.js checks
|
|
2
2
|
// --org in every subcommand that has both a personal and an org meaning, and
|
|
3
3
|
// a write subcommand requires admin while list/describe only require
|
|
4
|
-
// membership - matching
|
|
4
|
+
// membership - matching workspaces.py's is_workspace_admin vs
|
|
5
|
+
// is_workspace_member split.
|
|
5
6
|
//
|
|
6
7
|
// Source-inspection style, like rotate-key.test.js: driving the real
|
|
7
|
-
// subcommands would need a live JWT and a real
|
|
8
|
-
// doesn't have. What it can check without either is that the wiring
|
|
9
|
-
// and that a write path can't skip the admin check.
|
|
8
|
+
// subcommands would need a live JWT and a real workspace, which this test
|
|
9
|
+
// suite doesn't have. What it can check without either is that the wiring
|
|
10
|
+
// exists and that a write path can't skip the admin check.
|
|
10
11
|
import { test } from 'node:test';
|
|
11
12
|
import assert from 'node:assert';
|
|
12
13
|
import { readFileSync } from 'node:fs';
|
|
@@ -18,15 +19,15 @@ const apiSrc = readFileSync(join(__dirname, '..', 'api.js'), 'utf8');
|
|
|
18
19
|
const polSrc = readFileSync(join(__dirname, '..', 'policies.js'), 'utf8');
|
|
19
20
|
|
|
20
21
|
test('the org policy routes are wired in the api client', () => {
|
|
21
|
-
for (const path of ["'/
|
|
22
|
+
for (const path of ["'/workspaces/current'", '`/workspaces/${workspaceId}/policies`', '`/workspaces/${workspaceId}/policies/${id}`']) {
|
|
22
23
|
assert.ok(apiSrc.includes(path), `${path} not wired in api.js`);
|
|
23
24
|
}
|
|
24
25
|
});
|
|
25
26
|
|
|
26
27
|
test('every write subcommand resolves org context with admin required', () => {
|
|
27
28
|
// Each of these blocks should call _resolveOrgContext(jwt, true) when
|
|
28
|
-
// flags.org is set, mirroring
|
|
29
|
-
// POST/PATCH/DELETE to /
|
|
29
|
+
// flags.org is set, mirroring workspaces.py's is_workspace_admin gate on
|
|
30
|
+
// every POST/PATCH/DELETE to /workspaces/:id/policies*.
|
|
30
31
|
const writeSubcommands = ["case 'delete'", "case 'pause'", "case 'set-priority'"];
|
|
31
32
|
for (const marker of writeSubcommands) {
|
|
32
33
|
const start = polSrc.indexOf(marker);
|
|
@@ -44,9 +45,9 @@ test('create requires admin for --org but read subcommands do not', () => {
|
|
|
44
45
|
const createChunk = polSrc.slice(createStart, createStart + 4000);
|
|
45
46
|
assert.ok(/_resolveOrgContext\(jwt,\s*true\)/.test(createChunk), 'create --org must require admin');
|
|
46
47
|
|
|
47
|
-
// list/describe are read-only -
|
|
48
|
-
// any active member, not just admins. Passing `false` here is what
|
|
49
|
-
// that distinction actually reach the CLI instead of over-restricting reads.
|
|
48
|
+
// list/describe are read-only - workspaces.py's handle_list_workspace_policies
|
|
49
|
+
// allows any active member, not just admins. Passing `false` here is what
|
|
50
|
+
// makes that distinction actually reach the CLI instead of over-restricting reads.
|
|
50
51
|
const listStart = polSrc.indexOf("case 'list':");
|
|
51
52
|
const listChunk = polSrc.slice(listStart, listStart + 800);
|
|
52
53
|
assert.ok(/_resolveOrgContext\(jwt,\s*false\)/.test(listChunk), 'list --org should not require admin (read access is for any member)');
|
|
@@ -55,6 +56,6 @@ test('create requires admin for --org but read subcommands do not', () => {
|
|
|
55
56
|
test('org policy creation sends the domain, unlike the old payment-only path', () => {
|
|
56
57
|
const createStart = polSrc.indexOf("case 'create'");
|
|
57
58
|
const chunk = polSrc.slice(createStart, polSrc.indexOf('createOrgPolicy', createStart) + 50);
|
|
58
|
-
assert.ok(/createOrgPolicy\(jwt,\s*
|
|
59
|
+
assert.ok(/createOrgPolicy\(jwt,\s*workspace\.id,\s*body\)/.test(chunk));
|
|
59
60
|
assert.ok(/body\s*=\s*\{\s*name,\s*action,\s*domain,\s*conditions,\s*or_conditions/.test(chunk), 'org policy body must include domain - without it every org policy defaults to payment');
|
|
60
61
|
});
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
// hook step - a desktop-only machine used to get zero settings.json
|
|
10
10
|
// writes at all.
|
|
11
11
|
// 2. bin/troxy.js registers `troxy proxy status/enable/disable`, wired to
|
|
12
|
-
// src/proxy.js, and
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// src/proxy.js, and IS listed in the default help banner - init.js
|
|
13
|
+
// already asks every new user this question directly (see its own
|
|
14
|
+
// patchClaudeCodeInterception prompt), so by the time a user goes
|
|
15
|
+
// looking for `troxy proxy`, it's not an unvalidated feature they'd be
|
|
16
|
+
// opting into early, it's one they already have on and need to find.
|
|
15
17
|
|
|
16
18
|
import { describe, it } from 'node:test';
|
|
17
19
|
import assert from 'node:assert/strict';
|
|
@@ -89,10 +91,10 @@ describe('bin/troxy.js: proxy subcommand wiring', () => {
|
|
|
89
91
|
assert.match(proxyBlock, /runProxyStatus/);
|
|
90
92
|
});
|
|
91
93
|
|
|
92
|
-
it('is
|
|
94
|
+
it('is listed in the default help banner - init already enables it for real users', () => {
|
|
93
95
|
const helpBannerStart = binSrc.indexOf("Troxy: a secure control layer for AI agents");
|
|
94
96
|
const helpBanner = binSrc.slice(helpBannerStart);
|
|
95
|
-
assert.ok(
|
|
97
|
+
assert.ok(helpBanner.includes('troxy proxy '), 'troxy proxy should appear in the printed help banner');
|
|
96
98
|
});
|
|
97
99
|
});
|
|
98
100
|
|