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/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: ` troxy policies list\n\n Lists all policies in your account with their action, scope, status, and conditions.\n`,
16
- describe: ` troxy policies describe --name <policy-name>\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`,
17
- create: ` troxy policies create --name <name> --action <action> [options]\n troxy policies create --describe "<plain English>"\n\n Creates a new policy. Login required.\n\n AI builder:\n --describe "<text>" Describe the policy in plain English (e.g. "block\n Amazon purchases over $200") and Troxy AI drafts\n it for you to confirm. Rate-limited per day.\n\n Manual (required):\n --name Policy name\n --action ALLOW, BLOCK, NOTIFY, or ESCALATE\n\n Manual (optional conditions):\n --field Field to match: amount, merchant_name, tx_per_day\n --operator eq, neq, gt, gte, lt, lte, contains, starts_with, not_contains, between\n --value Comparison value (e.g. 500, amazon)\n --value2 Upper bound for 'between' operator\n\n Priority:\n --priority <n> Explicit priority number (default: auto, max+10)\n Lower number = higher priority (evaluated first).\n\n Scope (default: all agents, every MCP plus Troxy Chat):\n --mcp <name> Scope to one or more MCPs (comma-separated).\n Run 'troxy mcps list' to see MCP names.\n --chat Include Troxy Chat. Implied by default; combine with\n --mcp to scope to specific MCPs *and* chat, or use\n alone to scope to Troxy Chat only (no MCPs).\n --no-chat Exclude Troxy Chat from this policy's scope.\n\n Examples:\n troxy policies create --describe "block Amazon purchases over $200"\n troxy policies create --name "Block large" --action BLOCK --field amount --operator gte --value 500\n troxy policies create --name "Block Amazon" --action BLOCK --field merchant_name --operator contains --value amazon\n troxy policies create --name "Cap volume" --action BLOCK --field tx_per_day --operator gt --value 20\n troxy policies create --name "Allow Wiki" --action ALLOW --priority 5 --field merchant_name --operator contains --value Wiki\n troxy policies create --name "Laptop only" --action BLOCK --mcp "My Laptop" --field amount --operator gte --value 100\n troxy policies create --name "Multi-agent" --action BLOCK --mcp "My Laptop,Server" --field amount --operator gte --value 100\n troxy policies create --name "Chat only" --action ESCALATE --chat --field amount --operator gte --value 200\n troxy policies create --name "MCPs, no chat" --action BLOCK --no-chat --field merchant_name --operator contains --value casino\n`,
18
- 'set-priority': ` troxy policies set-priority --name <policy-name> --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\n Example:\n troxy policies set-priority --name "Block Wiki" --priority 5\n`,
19
- resume: ` troxy policies resume --name <policy-name>\n\n Resumes a paused policy.\n\n Options:\n --name Name of the policy to resume\n`,
20
- pause: ` troxy policies pause --name <policy-name>\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`,
21
- delete: ` troxy policies delete --name <policy-name>\n\n Permanently deletes a policy.\n\n Options:\n --name Name of the policy to delete\n`,
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.action,
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.action}
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 { tokens = [] } = await api.listTokens(jwt);
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 body = {
103
- name: draft.name, action: draft.action,
104
- conditions: draft.conditions || [], or_conditions: draft.or_conditions || [],
105
- enabled: true,
106
- global: !(draft.mcp_ids && draft.mcp_ids.length),
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
- const action = (flags.action || '').toUpperCase();
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
- if (!isGlobal && mcpIds.length === 0 && !appliesToChat) {
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
- console.error(' --priority must be a number\n'); process.exit(1);
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.createPolicy(jwt, body);
195
- let scope;
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|delete|enable|disable]\n');
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) {