troxy-cli 1.21.1 → 1.23.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/README.md +2 -1
- package/bin/troxy.js +97 -22
- package/package.json +7 -4
- package/src/api.js +18 -1
- package/src/data/policy-spec.json +496 -0
- package/src/data/templates.json +1429 -0
- package/src/init.js +13 -2
- package/src/mcp-server.js +86 -1
- package/src/mcps.js +1 -0
- package/src/policies.js +351 -114
- package/src/policy-fields.js +107 -0
- package/src/secrets.js +1 -1
- package/src/settings.js +0 -3
- package/src/simulate.js +251 -0
- package/src/tests/mcp-checkpoints.test.js +79 -0
- package/src/tests/model-checkpoint.test.js +5 -1
- package/src/tests/policies-create.test.js +218 -0
- package/src/tests/policies-org.test.js +60 -0
- package/src/tests/simulate.test.js +127 -0
- package/src/tests/templates.test.js +46 -0
- package/src/tests/tool_detect.test.js +389 -0
- package/src/tool_detect.js +181 -0
- package/src/chat-budget.js +0 -73
package/src/policies.js
CHANGED
|
@@ -1,39 +1,75 @@
|
|
|
1
1
|
import readline from 'readline';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
2
5
|
import { api } from './api.js';
|
|
3
6
|
import { requireJwt } from './auth.js';
|
|
4
7
|
import { table } from './print.js';
|
|
5
8
|
import { localTokenPrefix } from './config.js';
|
|
9
|
+
import { DOMAINS, DOMAIN_FIELDS, FIELD_SPECS, normalizeDomain, inferDomain, validateCondition } from './policy-fields.js';
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const { templates: TEMPLATES } = JSON.parse(readFileSync(join(__dirname, 'data', 'templates.json'), 'utf8'));
|
|
6
13
|
|
|
7
14
|
const DECISION_ICON = { ALLOW: '✓', BLOCK: '✗', ESCALATE: '⏳', NOTIFY: '~', TIERED: '⊕' };
|
|
8
15
|
|
|
16
|
+
// The top-level `action` a policy is stored with is stale for essentially
|
|
17
|
+
// every policy the dashboard's drawer builds - it defaults to ESCALATE and is
|
|
18
|
+
// never updated once a policy is built branch-by-branch, which is how the
|
|
19
|
+
// Rules section builds every one of them. `effective_action` is the server's
|
|
20
|
+
// resolved value (added alongside the raw column, never replacing it - see
|
|
21
|
+
// policies.py's _effective_action). Falling back to `p.action` here keeps
|
|
22
|
+
// this CLI working against a server that hasn't deployed the field yet.
|
|
23
|
+
export function _displayAction(p) {
|
|
24
|
+
return p.effective_action || p.action;
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
function _promptYN(question) {
|
|
10
28
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
11
29
|
return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim().toLowerCase() === 'y'); }));
|
|
12
30
|
}
|
|
13
31
|
|
|
14
32
|
const HELP = {
|
|
15
|
-
list:
|
|
16
|
-
describe:
|
|
17
|
-
create:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 organization's policies (any member can read them, admin-authored).\n`,
|
|
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
|
+
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
|
+
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`,
|
|
37
|
+
templates: ` troxy policies templates [--category "<name>"] [--search "<text>"]\n\n Lists the built-in policy templates (the same ones on the dashboard's\n Policies page). Use a template's exact label with 'policies create --template'.\n`,
|
|
38
|
+
test: ` troxy policies test --domain <domain> --example '<JSON>' [condition flags]\n\n Dry-runs a condition against an example event - the same "would this rule\n fire?" check the dashboard's policy drawer offers, without saving anything.\n Nothing is written: no audit row, no notification, no pending approval.\n\n Takes the same condition flags as 'create' (--field/--operator/--value/--and/--or/--json/--template),\n plus:\n --example '<JSON>' The event to test against, shaped like what that checkpoint receives.\n e.g. --domain payment --example '{\"amount\": 150}'\n\n Examples:\n troxy policies test --domain payment --field amount --operator gt --value 100 --example '{"amount": 150}'\n troxy policies test --domain secrets --field has_api_key --operator eq --value true --example '{"has_api_key": true}'\n`,
|
|
39
|
+
'set-priority': ` troxy policies set-priority --name <policy-name> --org --priority <n>\n\n Changes the priority of a policy. Lower number = higher priority (evaluated first).\n\n Options:\n --name Name of the policy\n --priority New priority number (e.g. 10, 20, 50)\n --org Required. Target the org policy (admin access required; personal-scope policies no longer exist)\n\n Example:\n troxy policies set-priority --name "Block Wiki" --priority 5 --org\n`,
|
|
40
|
+
resume: ` troxy policies resume --name <policy-name> --org\n\n Resumes a paused policy.\n\n Options:\n --name Name of the policy to resume\n --org Required. Target the org policy (admin access required; personal-scope policies no longer exist)\n`,
|
|
41
|
+
pause: ` troxy policies pause --name <policy-name> --org\n\n Pauses a policy without deleting it. The policy stops firing until you resume it.\n\n Options:\n --name Name of the policy to pause\n --org Required. Target the org policy (admin access required; personal-scope policies no longer exist)\n`,
|
|
42
|
+
delete: ` troxy policies delete --name <policy-name> --org\n\n Permanently deletes a policy.\n\n Options:\n --name Name of the policy to delete\n --org Required. Target the org policy (admin access required; personal-scope policies no longer exist)\n`,
|
|
22
43
|
};
|
|
23
44
|
|
|
24
|
-
export async function runPolicies([sub, ...args], flags) {
|
|
45
|
+
export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
|
|
25
46
|
if (flags.help || flags.h) {
|
|
26
|
-
console.log('\n' + (HELP[sub] || ` troxy policies <subcommand> [options]\n\n Subcommands:\n list List all policies\n describe Show details for a policy\n create Create a new policy\n set-priority Change a policy's priority\n pause Pause a policy (it stops firing until you resume it)\n resume Resume a paused policy\n delete Delete a policy\n\n Run 'troxy policies <subcommand> --help' for subcommand help.\n`));
|
|
47
|
+
console.log('\n' + (HELP[sub] || ` troxy policies <subcommand> [options]\n\n Subcommands:\n list List all policies\n describe Show details for a policy\n create Create a new policy\n templates List the built-in templates\n fields List the fields/operators each checkpoint supports\n test Dry-run a condition without saving it\n set-priority Change a policy's priority\n pause Pause a policy (it stops firing until you resume it)\n resume Resume a paused policy\n delete Delete a policy\n\n Policies are admin-created and always org- or space-scoped - personal policies\n no longer exist. create/set-priority/pause/resume/delete all require --org\n (admin access). list/describe/test need no --org and show every org + space\n policy that applies to you.\n\n Run 'troxy policies <subcommand> --help' for subcommand help.\n`));
|
|
27
48
|
process.exit(0);
|
|
28
49
|
}
|
|
29
50
|
|
|
51
|
+
// Local, no login and no network - just reads the vendored spec/template files.
|
|
52
|
+
if (sub === 'fields') { _printFields(flags); return; }
|
|
53
|
+
if (sub === 'templates') { _printTemplates(flags); return; }
|
|
54
|
+
|
|
30
55
|
// Read-only subcommands work with a login session
|
|
31
|
-
const readOnly = !sub || sub === 'list' || sub === 'describe';
|
|
56
|
+
const readOnly = !sub || sub === 'list' || sub === 'describe' || sub === 'test';
|
|
32
57
|
|
|
33
58
|
if (readOnly) {
|
|
34
59
|
const jwt = requireJwt();
|
|
35
60
|
switch (sub || 'list') {
|
|
36
61
|
case 'list': {
|
|
62
|
+
if (flags.org) {
|
|
63
|
+
const org = await _resolveOrgContext(jwt, false);
|
|
64
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, org.id);
|
|
65
|
+
if (!policies.length) { console.log(`\n No org policies yet in ${org.name}.\n`); return; }
|
|
66
|
+
console.log(`\n Organization: ${org.name}`);
|
|
67
|
+
table(
|
|
68
|
+
['#', 'Name', 'Domain', 'Action', 'Status', 'Conditions'],
|
|
69
|
+
policies.map(p => [p.priority, p.name, p.domain, _displayAction(p), p.enabled ? 'active' : 'paused', _condSummary(p)]),
|
|
70
|
+
);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
37
73
|
const data = await api.agentPolicies(jwt, localTokenPrefix());
|
|
38
74
|
const policies = data?.policies || [];
|
|
39
75
|
if (!policies.length) { console.log('\n No policies yet.\n'); return; }
|
|
@@ -43,7 +79,7 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
43
79
|
policies.map(p => [
|
|
44
80
|
p.priority,
|
|
45
81
|
p.name,
|
|
46
|
-
p
|
|
82
|
+
_displayAction(p),
|
|
47
83
|
p.scope,
|
|
48
84
|
p.enabled ? 'active' : 'paused',
|
|
49
85
|
_condSummary(p),
|
|
@@ -56,25 +92,64 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
56
92
|
case 'describe': {
|
|
57
93
|
const name = flags.name;
|
|
58
94
|
if (!name) { console.error(' --name is required (tip: use single quotes for names with special chars)\n'); process.exit(1); }
|
|
95
|
+
if (flags.org) {
|
|
96
|
+
const org = await _resolveOrgContext(jwt, false);
|
|
97
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, org.id);
|
|
98
|
+
const p = policies.find(x => x.name.toLowerCase() === name.toLowerCase());
|
|
99
|
+
if (!p) { console.error(` Org policy "${name}" not found in ${org.name}\n`); process.exit(1); }
|
|
100
|
+
console.log(`
|
|
101
|
+
Name: ${p.name}
|
|
102
|
+
Domain: ${p.domain}
|
|
103
|
+
Action: ${_displayAction(p)}
|
|
104
|
+
Priority: ${p.priority}
|
|
105
|
+
Status: ${p.enabled ? 'active' : 'paused'}
|
|
106
|
+
Scope: org-wide (${org.name})
|
|
107
|
+
Conditions: ${_condDetail(p)}
|
|
108
|
+
Created: ${new Date(p.created_at).toLocaleDateString()}
|
|
109
|
+
`);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
59
112
|
const data = await api.agentPolicies(jwt, localTokenPrefix());
|
|
60
113
|
const p = (data?.policies || []).find(x => x.name.toLowerCase() === name.toLowerCase());
|
|
61
114
|
if (!p) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
|
|
62
115
|
|
|
63
|
-
const mcpList = p.mcps.length ? p.mcps.map(m => m.name).join(', ') : (p.global ? 'all MCPs' : 'none');
|
|
64
116
|
console.log(`
|
|
65
117
|
Name: ${p.name}
|
|
66
|
-
Action: ${p
|
|
118
|
+
Action: ${_displayAction(p)}
|
|
67
119
|
Priority: ${p.priority}
|
|
68
120
|
Status: ${p.enabled ? 'active' : 'paused'}
|
|
69
121
|
Scope: ${p.scope}
|
|
70
122
|
Applies here: ${p.applies_to_me ? 'yes' : 'no'}
|
|
71
|
-
MCPs: ${mcpList}
|
|
72
|
-
Troxy Chat: ${p.applies_to_chat ? 'yes' : 'no'}
|
|
73
123
|
Conditions: ${_condDetail(p)}
|
|
74
124
|
Created: ${new Date(p.created_at).toLocaleDateString()}
|
|
75
125
|
`);
|
|
76
126
|
break;
|
|
77
127
|
}
|
|
128
|
+
|
|
129
|
+
case 'test': {
|
|
130
|
+
let example;
|
|
131
|
+
try { example = flags.example ? JSON.parse(flags.example) : {}; }
|
|
132
|
+
catch { console.error(' --example must be valid JSON, e.g. --example \'{"amount": 150}\'\n'); process.exit(1); }
|
|
133
|
+
|
|
134
|
+
const { domain, action, conditions, or_conditions } = _buildConditionsFromFlags(flags, multiFlags, { requireAction: false });
|
|
135
|
+
const result = await api.previewPolicy(jwt, { domain, action: action || 'ESCALATE', conditions, or_conditions, example });
|
|
136
|
+
|
|
137
|
+
if (result.would_fire) {
|
|
138
|
+
console.log(`\n ${DECISION_ICON[result.decision] || '?'} Would fire: ${result.decision}`);
|
|
139
|
+
} else {
|
|
140
|
+
console.log(`\n — Would NOT fire (falls through to the agent's own default action)`);
|
|
141
|
+
}
|
|
142
|
+
(result.branches || []).forEach((b, i) => {
|
|
143
|
+
console.log(`\n Branch ${i + 1} (${b.action})${b.matched ? ' ✓ matched' : ''}`);
|
|
144
|
+
(b.conditions || []).forEach(c => {
|
|
145
|
+
const mark = c.matched ? '✓' : '✗';
|
|
146
|
+
const note = c.field_present_in_example === false ? ' (this field is not in --example - it reads as empty)' : '';
|
|
147
|
+
console.log(` ${mark} ${c.field} ${c.operator} ${c.value ?? ''}${note}`);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
console.log();
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
78
153
|
}
|
|
79
154
|
return;
|
|
80
155
|
}
|
|
@@ -84,129 +159,78 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
84
159
|
|
|
85
160
|
switch (sub) {
|
|
86
161
|
case 'create': {
|
|
162
|
+
// Personal-scope and per-MCP policy creation were eliminated (product
|
|
163
|
+
// decision, 2026-08-20): POST /dashboard/policies no longer exists on
|
|
164
|
+
// the server at all (falls through to a bare 404), so every policy the
|
|
165
|
+
// CLI creates now has to go through /orgs/:id/policies instead. --org
|
|
166
|
+
// is required up front, before touching --describe/--template/manual
|
|
167
|
+
// condition parsing, so a user who forgets it gets this clear message
|
|
168
|
+
// instead of the server's generic "not found".
|
|
169
|
+
if (!flags.org) {
|
|
170
|
+
console.error('\n Personal policies no longer exist - policy creation is admin-only and always org-wide.');
|
|
171
|
+
console.error(' Pass --org (admin access required). Space-scoped creation isn\'t available from the CLI yet - use the dashboard for that.\n');
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
87
175
|
if (flags.describe) {
|
|
176
|
+
// handle_ai_policy (the backend endpoint this hits) only knows two field
|
|
177
|
+
// allowlists, gated on a single is_email boolean - unlike the six-domain
|
|
178
|
+
// condition translator create's other paths use. Nothing here used to
|
|
179
|
+
// set it, so --describe silently ran every request through the payment
|
|
180
|
+
// allowlist regardless of what was actually described: an email policy
|
|
181
|
+
// would get its proposed fields dropped as "not allowed" one by one.
|
|
182
|
+
const describeDomain = flags.domain ? normalizeDomain(flags.domain) : 'payment';
|
|
183
|
+
if (!['payment', 'comms'].includes(describeDomain)) {
|
|
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
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
const org = await _resolveOrgContext(jwt, true);
|
|
88
188
|
process.stdout.write('\n Asking Troxy AI to build this policy... ');
|
|
89
|
-
const
|
|
90
|
-
const mcps = tokens.map(t => ({ id: t.id, name: t.name || t.agent_name || t.prefix }));
|
|
91
|
-
const draft = await api.aiPolicy(jwt, { text: flags.describe, mcps });
|
|
189
|
+
const draft = await api.aiPolicy(jwt, { text: flags.describe, is_email: describeDomain === 'comms' });
|
|
92
190
|
if (draft.error) { console.log('✗'); console.error(`\n ${draft.error}\n`); process.exit(1); }
|
|
93
191
|
console.log('✓\n');
|
|
94
192
|
console.log(` Name: ${draft.name}`);
|
|
95
193
|
console.log(` Action: ${draft.action}`);
|
|
96
194
|
if (draft.conditions?.length) console.log(` Conditions: ${_condDetail(draft)}`);
|
|
97
|
-
if (draft.mcp_ids?.length) console.log(` Scope: ${draft.mcp_ids.length} MCP(s)`);
|
|
98
195
|
if (draft.warnings?.length) console.log(` Warnings: ${draft.warnings.join('; ')}`);
|
|
99
196
|
if (draft.note) console.log(` Note: ${draft.note}`);
|
|
100
197
|
const ok = await _promptYN('\n Create this policy? (y/N): ');
|
|
101
198
|
if (!ok) { console.log('\n Cancelled.\n'); process.exit(0); }
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
mcp_ids: draft.mcp_ids || [],
|
|
108
|
-
applies_to_chat: true,
|
|
109
|
-
source: 'ai',
|
|
110
|
-
};
|
|
111
|
-
const policy = await api.createPolicy(jwt, body);
|
|
112
|
-
console.log(`\n Policy "${policy.name}" created ✓ (priority: ${policy.priority})\n`);
|
|
199
|
+
const name = draft.name, action = draft.action, domain = describeDomain,
|
|
200
|
+
conditions = draft.conditions || [], or_conditions = draft.or_conditions || [];
|
|
201
|
+
const body = { name, action, domain, conditions, or_conditions };
|
|
202
|
+
const policy = await api.createOrgPolicy(jwt, org.id, body);
|
|
203
|
+
console.log(`\n Org policy "${policy.name}" created ✓ (priority: ${policy.priority}, applies to every member of ${org.name})\n`);
|
|
113
204
|
break;
|
|
114
205
|
}
|
|
115
206
|
|
|
116
207
|
const name = flags.name;
|
|
117
|
-
|
|
118
|
-
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
119
|
-
if (!action) { console.error(' --action is required\n'); process.exit(1); }
|
|
120
|
-
if (!['ALLOW','BLOCK','ESCALATE','NOTIFY'].includes(action)) {
|
|
121
|
-
console.error(' --action must be ALLOW, BLOCK, ESCALATE, or NOTIFY\n');
|
|
122
|
-
process.exit(1);
|
|
123
|
-
}
|
|
124
|
-
const VALID_FIELDS = ['amount', 'merchant_name', 'tx_per_day'];
|
|
125
|
-
const FIELD_OPERATORS = {
|
|
126
|
-
amount: ['lt', 'lte', 'gt', 'gte', 'eq', 'between'],
|
|
127
|
-
merchant_name: ['eq', 'neq', 'contains', 'not_contains', 'starts_with'],
|
|
128
|
-
tx_per_day: ['lt', 'lte', 'gt', 'gte'],
|
|
129
|
-
};
|
|
130
|
-
const conditions = [];
|
|
131
|
-
if (flags.field) {
|
|
132
|
-
if (!VALID_FIELDS.includes(flags.field)) {
|
|
133
|
-
console.error(` --field must be one of: ${VALID_FIELDS.join(', ')}\n`); process.exit(1);
|
|
134
|
-
}
|
|
135
|
-
if (!flags.operator) { console.error(' --operator is required with --field\n'); process.exit(1); }
|
|
136
|
-
const validOps = FIELD_OPERATORS[flags.field];
|
|
137
|
-
if (!validOps.includes(flags.operator)) {
|
|
138
|
-
console.error(` --operator for ${flags.field} must be one of: ${validOps.join(', ')}\n`); process.exit(1);
|
|
139
|
-
}
|
|
140
|
-
if (flags.value == null) { console.error(' --value is required with --field\n'); process.exit(1); }
|
|
141
|
-
if (flags.operator === 'between' && flags.value2 == null) {
|
|
142
|
-
console.error(' --value2 is required with --operator between\n'); process.exit(1);
|
|
143
|
-
}
|
|
144
|
-
const cond = { field: flags.field, operator: flags.operator, value: flags.value };
|
|
145
|
-
if (flags.value2 != null) cond.value2 = flags.value2;
|
|
146
|
-
conditions.push(cond);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (flags.chat && flags['no-chat']) {
|
|
150
|
-
console.error(' --chat and --no-chat cannot both be set\n'); process.exit(1);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// --mcp "name" or "name1,name2": scope policy to specific MCPs instead of all.
|
|
154
|
-
// --chat / --no-chat: include or exclude Troxy Chat (default: included).
|
|
155
|
-
let isGlobal = true;
|
|
156
|
-
let mcpIds = [];
|
|
157
|
-
let mcpNames = [];
|
|
158
|
-
if (flags.mcp) {
|
|
159
|
-
const { tokens = [] } = await api.listTokens(jwt);
|
|
160
|
-
const requested = String(flags.mcp).split(',').map(s => s.trim()).filter(Boolean);
|
|
161
|
-
for (const raw of requested) {
|
|
162
|
-
const needle = raw.toLowerCase();
|
|
163
|
-
const match = tokens.find(t =>
|
|
164
|
-
(t.name && t.name.toLowerCase() === needle) ||
|
|
165
|
-
(t.agent_name && t.agent_name.toLowerCase() === needle) ||
|
|
166
|
-
(t.prefix && t.prefix.toLowerCase().startsWith(needle))
|
|
167
|
-
);
|
|
168
|
-
if (!match) {
|
|
169
|
-
console.error(`\n MCP "${raw}" not found. Run: troxy mcps list\n`);
|
|
170
|
-
process.exit(1);
|
|
171
|
-
}
|
|
172
|
-
mcpIds.push(match.id);
|
|
173
|
-
mcpNames.push(match.name || match.agent_name || match.prefix);
|
|
174
|
-
}
|
|
175
|
-
isGlobal = false;
|
|
176
|
-
console.log(`\n Scoping to MCP${mcpNames.length > 1 ? 's' : ''}: ${mcpNames.join(', ')}`);
|
|
177
|
-
} else if (flags.chat) {
|
|
178
|
-
// --chat with no --mcp: scope to Troxy Chat only, no MCPs
|
|
179
|
-
isGlobal = false;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const appliesToChat = flags['no-chat'] ? false : true;
|
|
208
|
+
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
183
209
|
|
|
184
|
-
|
|
185
|
-
console.error(' Nothing to scope to, pass --mcp, --chat, or drop --no-chat\n'); process.exit(1);
|
|
186
|
-
}
|
|
210
|
+
const { domain, action, conditions, or_conditions } = _buildConditionsFromFlags(flags, multiFlags, { requireAction: true });
|
|
187
211
|
|
|
212
|
+
const org = await _resolveOrgContext(jwt, true);
|
|
188
213
|
const priority = flags.priority != null ? parseInt(flags.priority, 10) : undefined;
|
|
189
|
-
if (flags.priority != null && isNaN(priority)) {
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
const body = { name, action, conditions, enabled: true, global: isGlobal, mcp_ids: mcpIds, applies_to_chat: appliesToChat };
|
|
214
|
+
if (flags.priority != null && isNaN(priority)) { console.error(' --priority must be a number\n'); process.exit(1); }
|
|
215
|
+
const body = { name, action, domain, conditions, or_conditions, enabled: true };
|
|
193
216
|
if (priority != null) body.priority = priority;
|
|
194
|
-
const policy = await api.
|
|
195
|
-
|
|
196
|
-
if (isGlobal) {
|
|
197
|
-
scope = appliesToChat ? 'all agents' : 'all MCPs';
|
|
198
|
-
} else {
|
|
199
|
-
const parts = mcpNames.slice();
|
|
200
|
-
if (appliesToChat) parts.push('Troxy Chat');
|
|
201
|
-
scope = parts.length ? parts.join(', ') : 'no agents';
|
|
202
|
-
}
|
|
203
|
-
console.log(`\n Policy "${policy.name}" created ✓ (priority: ${policy.priority}, scope: ${scope})\n`);
|
|
217
|
+
const policy = await api.createOrgPolicy(jwt, org.id, body);
|
|
218
|
+
console.log(`\n Org policy "${policy.name}" created ✓ (priority: ${policy.priority}, checkpoint: ${domain}, applies to every member of ${org.name})\n`);
|
|
204
219
|
break;
|
|
205
220
|
}
|
|
206
221
|
|
|
207
222
|
case 'delete': {
|
|
208
223
|
const name = flags.name;
|
|
209
224
|
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
225
|
+
if (flags.org) {
|
|
226
|
+
const org = await _resolveOrgContext(jwt, true);
|
|
227
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, org.id);
|
|
228
|
+
const policy = policies.find(p => p.name === name);
|
|
229
|
+
if (!policy) { console.error(` Org policy "${name}" not found in ${org.name}\n`); process.exit(1); }
|
|
230
|
+
await api.deleteOrgPolicy(jwt, org.id, policy.id);
|
|
231
|
+
console.log(`\n Org policy "${name}" deleted ✓\n`);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
210
234
|
const { policies = [] } = await api.listPolicies(jwt);
|
|
211
235
|
const policy = policies.find(p => p.name === name);
|
|
212
236
|
if (!policy) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
|
|
@@ -226,6 +250,15 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
226
250
|
case 'resume': {
|
|
227
251
|
const name = flags.name;
|
|
228
252
|
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
253
|
+
if (flags.org) {
|
|
254
|
+
const org = await _resolveOrgContext(jwt, true);
|
|
255
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, org.id);
|
|
256
|
+
const policy = policies.find(p => p.name === name);
|
|
257
|
+
if (!policy) { console.error(` Org policy "${name}" not found in ${org.name}\n`); process.exit(1); }
|
|
258
|
+
await api.updateOrgPolicy(jwt, org.id, policy.id, { enabled: sub === 'resume' });
|
|
259
|
+
console.log(`\n Org policy "${name}" ${sub}d ✓\n`);
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
229
262
|
const { policies = [] } = await api.listPolicies(jwt);
|
|
230
263
|
const policy = policies.find(p => p.name === name);
|
|
231
264
|
if (!policy) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
|
|
@@ -249,6 +282,15 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
249
282
|
if (flags.priority == null) { console.error(' --priority is required\n'); process.exit(1); }
|
|
250
283
|
const priority = parseInt(flags.priority, 10);
|
|
251
284
|
if (isNaN(priority)) { console.error(' --priority must be a number\n'); process.exit(1); }
|
|
285
|
+
if (flags.org) {
|
|
286
|
+
const org = await _resolveOrgContext(jwt, true);
|
|
287
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, org.id);
|
|
288
|
+
const policy = policies.find(p => p.name === name);
|
|
289
|
+
if (!policy) { console.error(` Org policy "${name}" not found in ${org.name}\n`); process.exit(1); }
|
|
290
|
+
await api.updateOrgPolicy(jwt, org.id, policy.id, { priority });
|
|
291
|
+
console.log(`\n Org policy "${name}" priority set to ${priority} ✓\n`);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
252
294
|
const { policies = [] } = await api.listPolicies(jwt);
|
|
253
295
|
const policy = policies.find(p => p.name === name);
|
|
254
296
|
if (!policy) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
|
|
@@ -269,11 +311,206 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
269
311
|
|
|
270
312
|
default:
|
|
271
313
|
console.error(` Unknown subcommand: ${sub}`);
|
|
272
|
-
console.error(' Usage: troxy policies [list|describe|create|set-priority|
|
|
314
|
+
console.error(' Usage: troxy policies [list|describe|create|templates|fields|test|set-priority|pause|resume|delete]\n');
|
|
273
315
|
process.exit(1);
|
|
274
316
|
}
|
|
275
317
|
}
|
|
276
318
|
|
|
319
|
+
// ── Org context ──────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
async function _resolveOrgContext(jwt, needAdmin) {
|
|
322
|
+
const { org } = await api.currentOrg(jwt);
|
|
323
|
+
if (!org) {
|
|
324
|
+
console.error('\n --org was passed, but you are not in an organization.\n');
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
if (needAdmin && org.role !== 'admin') {
|
|
328
|
+
console.error(`\n Org policies require admin access. You are a '${org.role}' in ${org.name}.\n`);
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
return org;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Condition building, shared by create and test ──────────────────────────
|
|
335
|
+
|
|
336
|
+
// "field=amount,operator=gt,value=100[,value2=200]" -> {field,operator,value,value2?}
|
|
337
|
+
function _parseMiniCondition(str, flagName) {
|
|
338
|
+
const out = {};
|
|
339
|
+
for (const pair of String(str).split(',')) {
|
|
340
|
+
const eq = pair.indexOf('=');
|
|
341
|
+
if (eq === -1) continue;
|
|
342
|
+
out[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
|
|
343
|
+
}
|
|
344
|
+
if (!out.field || !out.operator || out.value == null) {
|
|
345
|
+
console.error(`\n ${flagName} needs field=, operator= and value= - got: "${str}"\n`);
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function _condObj(c) {
|
|
352
|
+
const out = { field: c.field, operator: c.operator, value: c.value };
|
|
353
|
+
if (c.value2 != null) out.value2 = c.value2;
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function _validateOrExit(domain, c) {
|
|
358
|
+
const err = validateCondition(domain, c.field, c.operator, c.value, c.value2);
|
|
359
|
+
if (err) { console.error(`\n ${err}\n`); process.exit(1); }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Exported for direct testing (src/tests/policies-create.test.js) - these two
|
|
363
|
+
// carry all the domain/field/operator/value validation logic in this file,
|
|
364
|
+
// and testing them by driving the full CLI command through a mocked network
|
|
365
|
+
// would hide failures behind console-output diffing instead of asserting on
|
|
366
|
+
// the actual request body that would have been sent.
|
|
367
|
+
export function _findTemplate(label) {
|
|
368
|
+
const exact = TEMPLATES.find(t => t.label.toLowerCase() === label.toLowerCase());
|
|
369
|
+
if (exact) return exact;
|
|
370
|
+
const needle = label.toLowerCase();
|
|
371
|
+
const close = TEMPLATES.filter(t => t.label.toLowerCase().includes(needle)).slice(0, 5);
|
|
372
|
+
console.error(`\n No template called "${label}".`);
|
|
373
|
+
if (close.length) {
|
|
374
|
+
console.error(' Did you mean:');
|
|
375
|
+
close.forEach(t => console.error(` - ${t.label}`));
|
|
376
|
+
} else {
|
|
377
|
+
console.error(" Run 'troxy policies templates' to see all of them.");
|
|
378
|
+
}
|
|
379
|
+
console.error();
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Builds {domain, action, conditions, or_conditions} from create/test's flags.
|
|
385
|
+
* Exits with a clear error on anything invalid - nothing here should ever
|
|
386
|
+
* reach the server with a condition the vendored spec already knows is bad.
|
|
387
|
+
*/
|
|
388
|
+
export function _buildConditionsFromFlags(flags, multiFlags, { requireAction }) {
|
|
389
|
+
const exclusiveFlags = ['template', 'json', 'field', 'and', 'or'];
|
|
390
|
+
const given = exclusiveFlags.filter(f => flags[f] != null || (multiFlags[f] || []).length);
|
|
391
|
+
const usesTemplate = given.includes('template');
|
|
392
|
+
const usesJson = given.includes('json');
|
|
393
|
+
if (usesTemplate && given.some(f => f !== 'template')) {
|
|
394
|
+
console.error('\n --template cannot be combined with --field/--and/--or/--json. Use --value/--value2/--action to adjust it instead.\n');
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
397
|
+
if (usesJson && given.some(f => f !== 'json')) {
|
|
398
|
+
console.error('\n --json cannot be combined with --field/--and/--or/--template.\n');
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const action = (flags.action || '').toUpperCase();
|
|
403
|
+
if (requireAction && !action && !usesTemplate) { console.error(' --action is required\n'); process.exit(1); }
|
|
404
|
+
if (action && !['ALLOW', 'BLOCK', 'ESCALATE', 'NOTIFY'].includes(action)) {
|
|
405
|
+
console.error(' --action must be ALLOW, BLOCK, ESCALATE, or NOTIFY\n'); process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (usesTemplate) {
|
|
409
|
+
const t = _findTemplate(flags.template);
|
|
410
|
+
const domain = t.domain;
|
|
411
|
+
const finalAction = action || t.action;
|
|
412
|
+
let conditions = t.conditions.map(_condObj);
|
|
413
|
+
let or_conditions = t.or_conditions.map(b => ({ action: b.action, conditions: b.conditions.map(_condObj) }));
|
|
414
|
+
|
|
415
|
+
if (flags.value != null) {
|
|
416
|
+
const totalConds = conditions.length + or_conditions.reduce((n, b) => n + b.conditions.length, 0);
|
|
417
|
+
if (totalConds !== 1) {
|
|
418
|
+
console.error(`\n "${t.label}" has ${totalConds} condition(s) - --value only works on a template with exactly one. Use --json to customize this one, or add it as-is and edit it later.\n`);
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
if (conditions.length === 1) conditions = [{ ...conditions[0], value: flags.value, ...(flags.value2 != null ? { value2: flags.value2 } : {}) }];
|
|
422
|
+
else or_conditions = [{ ...or_conditions[0], conditions: [{ ...or_conditions[0].conditions[0], value: flags.value, ...(flags.value2 != null ? { value2: flags.value2 } : {}) }] }];
|
|
423
|
+
} else if (flags.value2 != null) {
|
|
424
|
+
console.error('\n --value2 requires --value.\n'); process.exit(1);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
for (const c of conditions) _validateOrExit(domain, c);
|
|
428
|
+
for (const b of or_conditions) for (const c of b.conditions) _validateOrExit(domain, c);
|
|
429
|
+
return { domain, action: finalAction, conditions, or_conditions };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (usesJson) {
|
|
433
|
+
let parsed;
|
|
434
|
+
try { parsed = JSON.parse(flags.json); }
|
|
435
|
+
catch { console.error('\n --json must be valid JSON: {"conditions": [...], "or_conditions": [...]}\n'); process.exit(1); }
|
|
436
|
+
const domain = normalizeDomain(flags.domain);
|
|
437
|
+
if (!domain) { console.error(`\n --domain is required with --json (one of: ${DOMAINS.join(', ')})\n`); process.exit(1); }
|
|
438
|
+
const conditions = (parsed.conditions || []).map(_condObj);
|
|
439
|
+
const or_conditions = (parsed.or_conditions || []).map(b => ({ action: b.action, conditions: (b.conditions || []).map(_condObj) }));
|
|
440
|
+
for (const c of conditions) _validateOrExit(domain, c);
|
|
441
|
+
for (const b of or_conditions) for (const c of b.conditions) _validateOrExit(domain, c);
|
|
442
|
+
return { domain, action, conditions, or_conditions };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Manual: --field (single) plus repeatable --and (AND'd in) and --or (branches).
|
|
446
|
+
let domain = normalizeDomain(flags.domain);
|
|
447
|
+
if (!domain && flags.field) domain = inferDomain(flags.field);
|
|
448
|
+
if (!domain && (multiFlags.and || multiFlags.or || []).length) {
|
|
449
|
+
console.error(`\n --domain is required (one of: ${DOMAINS.join(', ')})\n`); process.exit(1);
|
|
450
|
+
}
|
|
451
|
+
if (!domain) domain = 'payment'; // nothing given at all: a plain catch-all payment policy, same as before this change
|
|
452
|
+
|
|
453
|
+
const singleField = flags.field
|
|
454
|
+
? { field: flags.field, operator: flags.operator, value: flags.value, ...(flags.value2 != null ? { value2: flags.value2 } : {}) }
|
|
455
|
+
: null;
|
|
456
|
+
const andSpecs = (multiFlags.and || []).map(s => _parseMiniCondition(s, '--and'));
|
|
457
|
+
const orSpecs = (multiFlags.or || []).map(s => _parseMiniCondition(s, '--or'));
|
|
458
|
+
const baseAnd = [...(singleField ? [singleField] : []), ...andSpecs];
|
|
459
|
+
|
|
460
|
+
for (const c of baseAnd) _validateOrExit(domain, c);
|
|
461
|
+
for (const c of orSpecs) _validateOrExit(domain, c);
|
|
462
|
+
|
|
463
|
+
if (orSpecs.length === 0) {
|
|
464
|
+
return { domain, action, conditions: baseAnd, or_conditions: [] };
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
domain, action,
|
|
468
|
+
conditions: [],
|
|
469
|
+
or_conditions: orSpecs.map(o => ({ action, conditions: [...baseAnd, o] })),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── Local, offline listings ─────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
function _printFields(flags) {
|
|
476
|
+
const domain = flags.domain ? normalizeDomain(flags.domain) : null;
|
|
477
|
+
if (flags.domain && !domain) { console.error(`\n Unknown domain "${flags.domain}" (one of: ${DOMAINS.join(', ')})\n`); process.exit(1); }
|
|
478
|
+
const domainsToShow = domain ? [domain] : DOMAINS;
|
|
479
|
+
for (const d of domainsToShow) {
|
|
480
|
+
console.log(`\n ${d}`);
|
|
481
|
+
const fields = DOMAIN_FIELDS[d] || [];
|
|
482
|
+
table(
|
|
483
|
+
['Field', 'Operators', 'Values'],
|
|
484
|
+
fields.map(f => {
|
|
485
|
+
const spec = FIELD_SPECS[f] || {};
|
|
486
|
+
const values = spec.kind === 'bool' ? 'true, false' : (spec.values || []).join(', ');
|
|
487
|
+
return [f, (spec.ops || []).join(', '), values];
|
|
488
|
+
}),
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
console.log();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function _printTemplates(flags) {
|
|
495
|
+
let list = TEMPLATES;
|
|
496
|
+
if (flags.category) {
|
|
497
|
+
const needle = String(flags.category).toLowerCase();
|
|
498
|
+
list = list.filter(t => t.category.toLowerCase().includes(needle));
|
|
499
|
+
}
|
|
500
|
+
if (flags.search) {
|
|
501
|
+
const needle = String(flags.search).toLowerCase();
|
|
502
|
+
list = list.filter(t => t.label.toLowerCase().includes(needle) || (t.desc || '').toLowerCase().includes(needle));
|
|
503
|
+
}
|
|
504
|
+
if (!list.length) { console.log('\n No templates match.\n'); return; }
|
|
505
|
+
console.log();
|
|
506
|
+
let lastCategory = null;
|
|
507
|
+
for (const t of list) {
|
|
508
|
+
if (t.category !== lastCategory) { console.log(` ${t.category}`); lastCategory = t.category; }
|
|
509
|
+
console.log(` ${t.action.padEnd(8)} ${t.label}`);
|
|
510
|
+
}
|
|
511
|
+
console.log(`\n ${list.length} template(s). Use one with: troxy policies create --name "..." --template "<label>"\n`);
|
|
512
|
+
}
|
|
513
|
+
|
|
277
514
|
const _isAny = x => !x.field || x.field === 'any' || x.operator === 'any';
|
|
278
515
|
|
|
279
516
|
function _condSummary(p) {
|