troxy-cli 1.29.14 → 1.29.16

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