troxy-cli 1.21.1 → 1.22.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/init.js CHANGED
@@ -302,6 +302,17 @@ export async function runInit({ key, name } = {}) {
302
302
 
303
303
  await reprovisionKeyConsumers(key, agentName);
304
304
 
305
+ // Scan for local AI coding tools and (interactively) confirm the plan for
306
+ // each, so the dashboard shows real subscription cost, not token math.
307
+ // Non-fatal + TTY-gated inside runToolDetection - a scripted init never
308
+ // hangs and a scan failure never breaks setup.
309
+ try {
310
+ const { runToolDetection } = await import('./tool_detect.js');
311
+ await runToolDetection(key, { interactive: true });
312
+ } catch {
313
+ // Tool detection is a bonus, never a blocker for a successful init.
314
+ }
315
+
305
316
  console.log('\n Your payments are now protected.');
306
317
  console.log(' Dashboard → https://dash.troxy.io');
307
318
  console.log('\n For more information, visit https://docs.troxy.io\n');
package/src/mcp-server.js CHANGED
@@ -12,6 +12,13 @@ const evaluateLogin = (body, apiKey) => api.evaluateLogin(body, apiKey);
12
12
  const evaluateAction = (body, apiKey) => api.evaluateAction(body, apiKey);
13
13
  const evaluateModel = (body, apiKey) => api.evaluateModel(body, apiKey);
14
14
  const reportModelUsage = (body, apiKey) => api.reportModelUsage(body, apiKey);
15
+ // Was missing entirely until 2026-08-15: every other checkpoint had an MCP
16
+ // tool, this one did not, so no MCP-connected agent could ever call
17
+ // /evaluate/secrets - not "the agent chose not to," there was nothing to
18
+ // call. evaluate.py's other_checkpoints discovery (every response advertises
19
+ // the other five) does mention it, but as a raw HTTP URL; useless to an
20
+ // agent whose only way to reach Troxy is through the tools listed here.
21
+ const evaluateSecrets = (body, apiKey) => api.evaluateSecrets(body, apiKey);
15
22
 
16
23
  export async function runMcp() {
17
24
  const config = loadConfig();
@@ -374,6 +381,48 @@ export async function runMcp() {
374
381
  },
375
382
  },
376
383
  },
384
+ {
385
+ name: 'evaluate_secrets',
386
+ description:
387
+ 'Evaluate whether content you are about to send, write, or publish should be allowed, blocked, or ' +
388
+ 'escalated based on your Troxy policies. Troxy scans the content server-side for API keys, credit ' +
389
+ 'card numbers, passwords, private keys, tokens, and personally identifiable information - you do ' +
390
+ 'not pre-classify it yourself. Call this BEFORE you share a file, send a message, push code, upload ' +
391
+ 'data, or write content to any external destination - anything that leaves the current context. ' +
392
+ 'If you are unsure whether something counts as sensitive, call this anyway.',
393
+ inputSchema: {
394
+ type: 'object',
395
+ required: ['content'],
396
+ properties: {
397
+ content: {
398
+ type: 'string',
399
+ description: 'The text, code, or data you are about to send or write.',
400
+ },
401
+ destination: {
402
+ type: 'string',
403
+ description: 'Where it is going: a person, a repo, a channel, a service, or "public" if anyone can see it.',
404
+ },
405
+ action: {
406
+ type: 'string',
407
+ enum: ['send', 'push', 'upload', 'write', 'share'],
408
+ description: 'What you are doing with it.',
409
+ },
410
+ content_type: {
411
+ type: 'string',
412
+ enum: ['code', 'message', 'file', 'config', 'log', 'other'],
413
+ description: 'What it is.',
414
+ },
415
+ agent: {
416
+ type: 'string',
417
+ description: 'Name of the agent (optional).',
418
+ },
419
+ approval_token: {
420
+ type: 'string',
421
+ description: 'Approval token from a previous ESCALATE response. Include this to proceed after the user has approved.',
422
+ },
423
+ },
424
+ },
425
+ },
377
426
  ],
378
427
  }));
379
428
 
@@ -608,6 +657,38 @@ export async function runMcp() {
608
657
  };
609
658
  }
610
659
 
660
+ if (toolName === 'evaluate_secrets') {
661
+ if (agentName && !args.agent) args.agent = agentName;
662
+ let result;
663
+ try {
664
+ result = await evaluateSecrets(args, apiKey);
665
+ } catch (err) {
666
+ return { content: [{ type: 'text', text: `Troxy error: ${err.message}` }], isError: true };
667
+ }
668
+ if (result.error) {
669
+ return { content: [{ type: 'text', text: `Troxy error: ${result.error}` }], isError: true };
670
+ }
671
+ const { decision, reason, audit_id, approval_token, detections } = result;
672
+ const found = (detections || []).map(d => d.type).join(', ');
673
+ const detail = found ? ` Found: ${found}.` : '';
674
+ let secretsText;
675
+ switch (decision) {
676
+ case 'ALLOW':
677
+ case 'NOTIFY':
678
+ secretsText = `✓ Approved.${reason ? ` ${reason}` : ''}${detail} You may proceed. (audit: ${audit_id})`;
679
+ break;
680
+ case 'BLOCK':
681
+ secretsText = `✗ Blocked.${reason ? ` ${reason}` : ''}${detail} Do not send, write, or publish this content as-is. (audit: ${audit_id})`;
682
+ break;
683
+ case 'ESCALATE':
684
+ secretsText = `⏳ This requires human approval; a request has been sent to the account owner.${detail}\n\nApproval token: ${approval_token}\n\nNow call wait_for_approval(approval_token="${approval_token}") to automatically detect approval, then call evaluate_secrets again with the same arguments PLUS this approval_token. Do not proceed until it returns approved.`;
685
+ break;
686
+ default:
687
+ secretsText = JSON.stringify(result);
688
+ }
689
+ return { content: [{ type: 'text', text: secretsText }], isError: decision === 'BLOCK' };
690
+ }
691
+
611
692
  if (toolName !== 'evaluate_payment') {
612
693
  throw new Error(`Unknown tool: ${toolName}`);
613
694
  }
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 policies. --org lists your organization's policies (any member can read them) instead of your personal ones.\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 instead of a personal one\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.\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 (needs an org AI key; --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 Scope (personal policies only; default: all agents, every MCP plus Troxy Chat):\n --mcp <name> Scope to one or more MCPs (comma-separated). Run 'troxy mcps list' for names.\n --chat Include Troxy Chat. Implied by default; combine with --mcp for MCPs *and* chat.\n --no-chat Exclude Troxy Chat.\n\n Org policies (applies to every member; requires admin):\n --org Create an org-wide policy instead of a personal one. No --mcp/--chat scoping - it's org-wide.\n\n Examples:\n troxy policies create --name "Block a secret going public" --template "Block a secret going somewhere public"\n troxy policies create --name "Cap over 100" --action BLOCK --domain payment --field amount --operator gt --value 100\n troxy policies create --name "Signups" --action ESCALATE --domain access --field login_action --operator eq --value signup\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"\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"\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> --priority <n> [--org]\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 Target an org policy (requires admin)\n\n Example:\n troxy policies set-priority --name "Block Wiki" --priority 5\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 Target an org policy (requires admin)\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 Target an org policy (requires admin)\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 Target an org policy (requires admin)\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 Any personal-policy subcommand also takes --org, to act on your organization's policies instead (admin required to write).\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,6 +92,23 @@ 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); }
@@ -63,7 +116,7 @@ export async function runPolicies([sub, ...args], flags) {
63
116
  const mcpList = p.mcps.length ? p.mcps.map(m => m.name).join(', ') : (p.global ? 'all MCPs' : 'none');
64
117
  console.log(`
65
118
  Name: ${p.name}
66
- Action: ${p.action}
119
+ Action: ${_displayAction(p)}
67
120
  Priority: ${p.priority}
68
121
  Status: ${p.enabled ? 'active' : 'paused'}
69
122
  Scope: ${p.scope}
@@ -75,6 +128,31 @@ export async function runPolicies([sub, ...args], flags) {
75
128
  `);
76
129
  break;
77
130
  }
131
+
132
+ case 'test': {
133
+ let example;
134
+ try { example = flags.example ? JSON.parse(flags.example) : {}; }
135
+ catch { console.error(' --example must be valid JSON, e.g. --example \'{"amount": 150}\'\n'); process.exit(1); }
136
+
137
+ const { domain, action, conditions, or_conditions } = _buildConditionsFromFlags(flags, multiFlags, { requireAction: false });
138
+ const result = await api.previewPolicy(jwt, { domain, action: action || 'ESCALATE', conditions, or_conditions, example });
139
+
140
+ if (result.would_fire) {
141
+ console.log(`\n ${DECISION_ICON[result.decision] || '?'} Would fire: ${result.decision}`);
142
+ } else {
143
+ console.log(`\n — Would NOT fire (falls through to the agent's own default action)`);
144
+ }
145
+ (result.branches || []).forEach((b, i) => {
146
+ console.log(`\n Branch ${i + 1} (${b.action})${b.matched ? ' ✓ matched' : ''}`);
147
+ (b.conditions || []).forEach(c => {
148
+ const mark = c.matched ? '✓' : '✗';
149
+ const note = c.field_present_in_example === false ? ' (this field is not in --example - it reads as empty)' : '';
150
+ console.log(` ${mark} ${c.field} ${c.operator} ${c.value ?? ''}${note}`);
151
+ });
152
+ });
153
+ console.log();
154
+ break;
155
+ }
78
156
  }
79
157
  return;
80
158
  }
@@ -85,10 +163,22 @@ export async function runPolicies([sub, ...args], flags) {
85
163
  switch (sub) {
86
164
  case 'create': {
87
165
  if (flags.describe) {
166
+ if (flags.org) { console.error('\n --describe (AI builder) does not support --org yet. Build the policy manually or apply a template.\n'); process.exit(1); }
167
+ // handle_ai_policy (the backend endpoint this hits) only knows two field
168
+ // allowlists, gated on a single is_email boolean - unlike the six-domain
169
+ // condition translator create's other paths use. Nothing here used to
170
+ // set it, so --describe silently ran every request through the payment
171
+ // allowlist regardless of what was actually described: an email policy
172
+ // would get its proposed fields dropped as "not allowed" one by one.
173
+ const describeDomain = flags.domain ? normalizeDomain(flags.domain) : 'payment';
174
+ if (!['payment', 'comms'].includes(describeDomain)) {
175
+ 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`);
176
+ process.exit(1);
177
+ }
88
178
  process.stdout.write('\n Asking Troxy AI to build this policy... ');
89
179
  const { tokens = [] } = await api.listTokens(jwt);
90
180
  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 });
181
+ const draft = await api.aiPolicy(jwt, { text: flags.describe, mcps, is_email: describeDomain === 'comms' });
92
182
  if (draft.error) { console.log('✗'); console.error(`\n ${draft.error}\n`); process.exit(1); }
93
183
  console.log('✓\n');
94
184
  console.log(` Name: ${draft.name}`);
@@ -114,36 +204,19 @@ export async function runPolicies([sub, ...args], flags) {
114
204
  }
115
205
 
116
206
  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);
207
+ if (!name) { console.error(' --name is required\n'); process.exit(1); }
208
+
209
+ const { domain, action, conditions, or_conditions } = _buildConditionsFromFlags(flags, multiFlags, { requireAction: true });
210
+
211
+ if (flags.org) {
212
+ const org = await _resolveOrgContext(jwt, true);
213
+ const priority = flags.priority != null ? parseInt(flags.priority, 10) : undefined;
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 };
216
+ if (priority != null) body.priority = priority;
217
+ const policy = await api.createOrgPolicy(jwt, org.id, body);
218
+ console.log(`\n Org policy "${policy.name}" created ✓ (priority: ${policy.priority}, applies to every member of ${org.name})\n`);
219
+ break;
147
220
  }
148
221
 
149
222
  if (flags.chat && flags['no-chat']) {
@@ -189,7 +262,7 @@ export async function runPolicies([sub, ...args], flags) {
189
262
  if (flags.priority != null && isNaN(priority)) {
190
263
  console.error(' --priority must be a number\n'); process.exit(1);
191
264
  }
192
- const body = { name, action, conditions, enabled: true, global: isGlobal, mcp_ids: mcpIds, applies_to_chat: appliesToChat };
265
+ const body = { name, action, domain, conditions, or_conditions, enabled: true, global: isGlobal, mcp_ids: mcpIds, applies_to_chat: appliesToChat };
193
266
  if (priority != null) body.priority = priority;
194
267
  const policy = await api.createPolicy(jwt, body);
195
268
  let scope;
@@ -200,13 +273,22 @@ export async function runPolicies([sub, ...args], flags) {
200
273
  if (appliesToChat) parts.push('Troxy Chat');
201
274
  scope = parts.length ? parts.join(', ') : 'no agents';
202
275
  }
203
- console.log(`\n Policy "${policy.name}" created ✓ (priority: ${policy.priority}, scope: ${scope})\n`);
276
+ console.log(`\n Policy "${policy.name}" created ✓ (priority: ${policy.priority}, checkpoint: ${domain}, scope: ${scope})\n`);
204
277
  break;
205
278
  }
206
279
 
207
280
  case 'delete': {
208
281
  const name = flags.name;
209
282
  if (!name) { console.error(' --name is required\n'); process.exit(1); }
283
+ if (flags.org) {
284
+ const org = await _resolveOrgContext(jwt, true);
285
+ const { policies = [] } = await api.listOrgPolicies(jwt, org.id);
286
+ const policy = policies.find(p => p.name === name);
287
+ if (!policy) { console.error(` Org policy "${name}" not found in ${org.name}\n`); process.exit(1); }
288
+ await api.deleteOrgPolicy(jwt, org.id, policy.id);
289
+ console.log(`\n Org policy "${name}" deleted ✓\n`);
290
+ break;
291
+ }
210
292
  const { policies = [] } = await api.listPolicies(jwt);
211
293
  const policy = policies.find(p => p.name === name);
212
294
  if (!policy) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
@@ -226,6 +308,15 @@ export async function runPolicies([sub, ...args], flags) {
226
308
  case 'resume': {
227
309
  const name = flags.name;
228
310
  if (!name) { console.error(' --name is required\n'); process.exit(1); }
311
+ if (flags.org) {
312
+ const org = await _resolveOrgContext(jwt, true);
313
+ const { policies = [] } = await api.listOrgPolicies(jwt, org.id);
314
+ const policy = policies.find(p => p.name === name);
315
+ if (!policy) { console.error(` Org policy "${name}" not found in ${org.name}\n`); process.exit(1); }
316
+ await api.updateOrgPolicy(jwt, org.id, policy.id, { enabled: sub === 'resume' });
317
+ console.log(`\n Org policy "${name}" ${sub}d ✓\n`);
318
+ break;
319
+ }
229
320
  const { policies = [] } = await api.listPolicies(jwt);
230
321
  const policy = policies.find(p => p.name === name);
231
322
  if (!policy) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
@@ -249,6 +340,15 @@ export async function runPolicies([sub, ...args], flags) {
249
340
  if (flags.priority == null) { console.error(' --priority is required\n'); process.exit(1); }
250
341
  const priority = parseInt(flags.priority, 10);
251
342
  if (isNaN(priority)) { console.error(' --priority must be a number\n'); process.exit(1); }
343
+ if (flags.org) {
344
+ const org = await _resolveOrgContext(jwt, true);
345
+ const { policies = [] } = await api.listOrgPolicies(jwt, org.id);
346
+ const policy = policies.find(p => p.name === name);
347
+ if (!policy) { console.error(` Org policy "${name}" not found in ${org.name}\n`); process.exit(1); }
348
+ await api.updateOrgPolicy(jwt, org.id, policy.id, { priority });
349
+ console.log(`\n Org policy "${name}" priority set to ${priority} ✓\n`);
350
+ break;
351
+ }
252
352
  const { policies = [] } = await api.listPolicies(jwt);
253
353
  const policy = policies.find(p => p.name === name);
254
354
  if (!policy) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
@@ -269,11 +369,206 @@ export async function runPolicies([sub, ...args], flags) {
269
369
 
270
370
  default:
271
371
  console.error(` Unknown subcommand: ${sub}`);
272
- console.error(' Usage: troxy policies [list|describe|create|set-priority|delete|enable|disable]\n');
372
+ console.error(' Usage: troxy policies [list|describe|create|templates|fields|test|set-priority|pause|resume|delete]\n');
273
373
  process.exit(1);
274
374
  }
275
375
  }
276
376
 
377
+ // ── Org context ──────────────────────────────────────────────────────────
378
+
379
+ async function _resolveOrgContext(jwt, needAdmin) {
380
+ const { org } = await api.currentOrg(jwt);
381
+ if (!org) {
382
+ console.error('\n --org was passed, but you are not in an organization.\n');
383
+ process.exit(1);
384
+ }
385
+ if (needAdmin && org.role !== 'admin') {
386
+ console.error(`\n Org policies require admin access. You are a '${org.role}' in ${org.name}.\n`);
387
+ process.exit(1);
388
+ }
389
+ return org;
390
+ }
391
+
392
+ // ── Condition building, shared by create and test ──────────────────────────
393
+
394
+ // "field=amount,operator=gt,value=100[,value2=200]" -> {field,operator,value,value2?}
395
+ function _parseMiniCondition(str, flagName) {
396
+ const out = {};
397
+ for (const pair of String(str).split(',')) {
398
+ const eq = pair.indexOf('=');
399
+ if (eq === -1) continue;
400
+ out[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
401
+ }
402
+ if (!out.field || !out.operator || out.value == null) {
403
+ console.error(`\n ${flagName} needs field=, operator= and value= - got: "${str}"\n`);
404
+ process.exit(1);
405
+ }
406
+ return out;
407
+ }
408
+
409
+ function _condObj(c) {
410
+ const out = { field: c.field, operator: c.operator, value: c.value };
411
+ if (c.value2 != null) out.value2 = c.value2;
412
+ return out;
413
+ }
414
+
415
+ function _validateOrExit(domain, c) {
416
+ const err = validateCondition(domain, c.field, c.operator, c.value, c.value2);
417
+ if (err) { console.error(`\n ${err}\n`); process.exit(1); }
418
+ }
419
+
420
+ // Exported for direct testing (src/tests/policies-create.test.js) - these two
421
+ // carry all the domain/field/operator/value validation logic in this file,
422
+ // and testing them by driving the full CLI command through a mocked network
423
+ // would hide failures behind console-output diffing instead of asserting on
424
+ // the actual request body that would have been sent.
425
+ export function _findTemplate(label) {
426
+ const exact = TEMPLATES.find(t => t.label.toLowerCase() === label.toLowerCase());
427
+ if (exact) return exact;
428
+ const needle = label.toLowerCase();
429
+ const close = TEMPLATES.filter(t => t.label.toLowerCase().includes(needle)).slice(0, 5);
430
+ console.error(`\n No template called "${label}".`);
431
+ if (close.length) {
432
+ console.error(' Did you mean:');
433
+ close.forEach(t => console.error(` - ${t.label}`));
434
+ } else {
435
+ console.error(" Run 'troxy policies templates' to see all of them.");
436
+ }
437
+ console.error();
438
+ process.exit(1);
439
+ }
440
+
441
+ /**
442
+ * Builds {domain, action, conditions, or_conditions} from create/test's flags.
443
+ * Exits with a clear error on anything invalid - nothing here should ever
444
+ * reach the server with a condition the vendored spec already knows is bad.
445
+ */
446
+ export function _buildConditionsFromFlags(flags, multiFlags, { requireAction }) {
447
+ const exclusiveFlags = ['template', 'json', 'field', 'and', 'or'];
448
+ const given = exclusiveFlags.filter(f => flags[f] != null || (multiFlags[f] || []).length);
449
+ const usesTemplate = given.includes('template');
450
+ const usesJson = given.includes('json');
451
+ if (usesTemplate && given.some(f => f !== 'template')) {
452
+ console.error('\n --template cannot be combined with --field/--and/--or/--json. Use --value/--value2/--action to adjust it instead.\n');
453
+ process.exit(1);
454
+ }
455
+ if (usesJson && given.some(f => f !== 'json')) {
456
+ console.error('\n --json cannot be combined with --field/--and/--or/--template.\n');
457
+ process.exit(1);
458
+ }
459
+
460
+ const action = (flags.action || '').toUpperCase();
461
+ if (requireAction && !action && !usesTemplate) { console.error(' --action is required\n'); process.exit(1); }
462
+ if (action && !['ALLOW', 'BLOCK', 'ESCALATE', 'NOTIFY'].includes(action)) {
463
+ console.error(' --action must be ALLOW, BLOCK, ESCALATE, or NOTIFY\n'); process.exit(1);
464
+ }
465
+
466
+ if (usesTemplate) {
467
+ const t = _findTemplate(flags.template);
468
+ const domain = t.domain;
469
+ const finalAction = action || t.action;
470
+ let conditions = t.conditions.map(_condObj);
471
+ let or_conditions = t.or_conditions.map(b => ({ action: b.action, conditions: b.conditions.map(_condObj) }));
472
+
473
+ if (flags.value != null) {
474
+ const totalConds = conditions.length + or_conditions.reduce((n, b) => n + b.conditions.length, 0);
475
+ if (totalConds !== 1) {
476
+ 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`);
477
+ process.exit(1);
478
+ }
479
+ if (conditions.length === 1) conditions = [{ ...conditions[0], value: flags.value, ...(flags.value2 != null ? { value2: flags.value2 } : {}) }];
480
+ else or_conditions = [{ ...or_conditions[0], conditions: [{ ...or_conditions[0].conditions[0], value: flags.value, ...(flags.value2 != null ? { value2: flags.value2 } : {}) }] }];
481
+ } else if (flags.value2 != null) {
482
+ console.error('\n --value2 requires --value.\n'); process.exit(1);
483
+ }
484
+
485
+ for (const c of conditions) _validateOrExit(domain, c);
486
+ for (const b of or_conditions) for (const c of b.conditions) _validateOrExit(domain, c);
487
+ return { domain, action: finalAction, conditions, or_conditions };
488
+ }
489
+
490
+ if (usesJson) {
491
+ let parsed;
492
+ try { parsed = JSON.parse(flags.json); }
493
+ catch { console.error('\n --json must be valid JSON: {"conditions": [...], "or_conditions": [...]}\n'); process.exit(1); }
494
+ const domain = normalizeDomain(flags.domain);
495
+ if (!domain) { console.error(`\n --domain is required with --json (one of: ${DOMAINS.join(', ')})\n`); process.exit(1); }
496
+ const conditions = (parsed.conditions || []).map(_condObj);
497
+ const or_conditions = (parsed.or_conditions || []).map(b => ({ action: b.action, conditions: (b.conditions || []).map(_condObj) }));
498
+ for (const c of conditions) _validateOrExit(domain, c);
499
+ for (const b of or_conditions) for (const c of b.conditions) _validateOrExit(domain, c);
500
+ return { domain, action, conditions, or_conditions };
501
+ }
502
+
503
+ // Manual: --field (single) plus repeatable --and (AND'd in) and --or (branches).
504
+ let domain = normalizeDomain(flags.domain);
505
+ if (!domain && flags.field) domain = inferDomain(flags.field);
506
+ if (!domain && (multiFlags.and || multiFlags.or || []).length) {
507
+ console.error(`\n --domain is required (one of: ${DOMAINS.join(', ')})\n`); process.exit(1);
508
+ }
509
+ if (!domain) domain = 'payment'; // nothing given at all: a plain catch-all payment policy, same as before this change
510
+
511
+ const singleField = flags.field
512
+ ? { field: flags.field, operator: flags.operator, value: flags.value, ...(flags.value2 != null ? { value2: flags.value2 } : {}) }
513
+ : null;
514
+ const andSpecs = (multiFlags.and || []).map(s => _parseMiniCondition(s, '--and'));
515
+ const orSpecs = (multiFlags.or || []).map(s => _parseMiniCondition(s, '--or'));
516
+ const baseAnd = [...(singleField ? [singleField] : []), ...andSpecs];
517
+
518
+ for (const c of baseAnd) _validateOrExit(domain, c);
519
+ for (const c of orSpecs) _validateOrExit(domain, c);
520
+
521
+ if (orSpecs.length === 0) {
522
+ return { domain, action, conditions: baseAnd, or_conditions: [] };
523
+ }
524
+ return {
525
+ domain, action,
526
+ conditions: [],
527
+ or_conditions: orSpecs.map(o => ({ action, conditions: [...baseAnd, o] })),
528
+ };
529
+ }
530
+
531
+ // ── Local, offline listings ─────────────────────────────────────────────────
532
+
533
+ function _printFields(flags) {
534
+ const domain = flags.domain ? normalizeDomain(flags.domain) : null;
535
+ if (flags.domain && !domain) { console.error(`\n Unknown domain "${flags.domain}" (one of: ${DOMAINS.join(', ')})\n`); process.exit(1); }
536
+ const domainsToShow = domain ? [domain] : DOMAINS;
537
+ for (const d of domainsToShow) {
538
+ console.log(`\n ${d}`);
539
+ const fields = DOMAIN_FIELDS[d] || [];
540
+ table(
541
+ ['Field', 'Operators', 'Values'],
542
+ fields.map(f => {
543
+ const spec = FIELD_SPECS[f] || {};
544
+ const values = spec.kind === 'bool' ? 'true, false' : (spec.values || []).join(', ');
545
+ return [f, (spec.ops || []).join(', '), values];
546
+ }),
547
+ );
548
+ }
549
+ console.log();
550
+ }
551
+
552
+ function _printTemplates(flags) {
553
+ let list = TEMPLATES;
554
+ if (flags.category) {
555
+ const needle = String(flags.category).toLowerCase();
556
+ list = list.filter(t => t.category.toLowerCase().includes(needle));
557
+ }
558
+ if (flags.search) {
559
+ const needle = String(flags.search).toLowerCase();
560
+ list = list.filter(t => t.label.toLowerCase().includes(needle) || (t.desc || '').toLowerCase().includes(needle));
561
+ }
562
+ if (!list.length) { console.log('\n No templates match.\n'); return; }
563
+ console.log();
564
+ let lastCategory = null;
565
+ for (const t of list) {
566
+ if (t.category !== lastCategory) { console.log(` ${t.category}`); lastCategory = t.category; }
567
+ console.log(` ${t.action.padEnd(8)} ${t.label}`);
568
+ }
569
+ console.log(`\n ${list.length} template(s). Use one with: troxy policies create --name "..." --template "<label>"\n`);
570
+ }
571
+
277
572
  const _isAny = x => !x.field || x.field === 'any' || x.operator === 'any';
278
573
 
279
574
  function _condSummary(p) {