@simonfestl/husky-cli 1.15.1 → 1.17.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.
@@ -1,10 +1,28 @@
1
1
  import { Command } from "commander";
2
- import { getConfig, setSessionConfig, clearSessionConfig, getSessionConfig } from "./config.js";
2
+ import { getConfig, setSessionConfig, clearSessionConfig, getSessionConfig, fetchAndCacheRole } from "./config.js";
3
3
  import { getPermissions, clearPermissionsCache, getCacheStatus, hasPermission, canAccessKnowledgeBase } from "../lib/permissions-cache.js";
4
4
  const API_KEY_ROLES = [
5
5
  "admin", "supervisor", "worker", "reviewer", "support",
6
6
  "purchasing", "ops", "e2e_agent", "pr_agent"
7
7
  ];
8
+ /**
9
+ * Sanitize error messages to prevent sensitive data leakage.
10
+ * Truncates long messages and removes potential secrets.
11
+ */
12
+ function sanitizeErrorMessage(message, maxLength = 200) {
13
+ if (!message)
14
+ return "Unknown error";
15
+ // Remove potential secrets (patterns like API keys, tokens, etc.)
16
+ let sanitized = message
17
+ .replace(/[a-zA-Z0-9_-]{32,}/g, "[REDACTED]") // Long alphanumeric strings
18
+ .replace(/Bearer\s+[^\s]+/gi, "Bearer [REDACTED]") // Bearer tokens
19
+ .replace(/key[=:]\s*[^\s,}]+/gi, "key=[REDACTED]"); // key=value patterns
20
+ // Truncate if too long
21
+ if (sanitized.length > maxLength) {
22
+ sanitized = sanitized.substring(0, maxLength) + "...";
23
+ }
24
+ return sanitized;
25
+ }
8
26
  async function apiRequest(path, options = {}) {
9
27
  const config = getConfig();
10
28
  if (!config.apiUrl || !config.apiKey) {
@@ -21,7 +39,8 @@ async function apiRequest(path, options = {}) {
21
39
  });
22
40
  if (!res.ok) {
23
41
  const error = await res.json().catch(() => ({ error: res.statusText }));
24
- throw new Error(error.message || error.error || `HTTP ${res.status}`);
42
+ const rawMessage = error.message || error.error || `HTTP ${res.status}`;
43
+ throw new Error(sanitizeErrorMessage(rawMessage));
25
44
  }
26
45
  return res.json();
27
46
  }
@@ -293,6 +312,8 @@ authCommand
293
312
  }
294
313
  const session = await res.json();
295
314
  setSessionConfig(session);
315
+ // Fetch and cache permissions for the new session role
316
+ await fetchAndCacheRole();
296
317
  if (options.json) {
297
318
  console.log(JSON.stringify({
298
319
  success: true,
@@ -418,6 +439,8 @@ authCommand
418
439
  }
419
440
  const session = await res.json();
420
441
  setSessionConfig(session);
442
+ // Refresh permissions for the session role
443
+ await fetchAndCacheRole();
421
444
  if (options.json) {
422
445
  console.log(JSON.stringify({
423
446
  success: true,
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
- type AgentRole = "supervisor" | "worker" | "reviewer" | "e2e_agent" | "pr_agent" | "support";
2
+ declare const VALID_ROLES: readonly ["admin", "supervisor", "worker", "reviewer", "e2e_agent", "pr_agent", "support", "devops", "purchasing", "ops"];
3
+ type AgentRole = typeof VALID_ROLES[number];
3
4
  interface Config {
4
5
  apiUrl?: string;
5
6
  apiKey?: string;
@@ -47,7 +48,8 @@ export declare function getConfig(): Config;
47
48
  export declare function saveConfig(config: Config): void;
48
49
  /**
49
50
  * Fetch role and permissions from /api/auth/whoami
50
- * Caches the result in config for 1 hour
51
+ * Uses session token (Bearer) if available, otherwise falls back to API key.
52
+ * Caches the result in config for 1 hour.
51
53
  */
52
54
  export declare function fetchAndCacheRole(): Promise<{
53
55
  role?: AgentRole;
@@ -58,7 +60,9 @@ export declare function fetchAndCacheRole(): Promise<{
58
60
  */
59
61
  export declare function hasPermission(permission: string): boolean;
60
62
  /**
61
- * Get current role from config (may be undefined if not fetched)
63
+ * Get current role from config.
64
+ * Prefers sessionRole (from auth login) over role (from API key) when session is active.
65
+ * Validates that the role is a known valid role before returning.
62
66
  */
63
67
  export declare function getRole(): AgentRole | undefined;
64
68
  /**
@@ -72,6 +76,7 @@ export declare function setSessionConfig(session: {
72
76
  expiresAt: string;
73
77
  agent: string;
74
78
  role: string;
79
+ permissions?: string[];
75
80
  }): void;
76
81
  export declare function clearSessionConfig(): void;
77
82
  export declare function getSessionConfig(): {
@@ -1,10 +1,18 @@
1
1
  import { Command } from "commander";
2
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { homedir } from "os";
5
5
  import { ErrorHelpers, errorWithHint, ExplainTopic } from "../lib/error-hints.js";
6
+ // Valid agent roles - used for runtime validation
7
+ const VALID_ROLES = ["admin", "supervisor", "worker", "reviewer", "e2e_agent", "pr_agent", "support", "devops", "purchasing", "ops"];
6
8
  const CONFIG_DIR = join(homedir(), ".husky");
7
9
  const CONFIG_FILE = join(CONFIG_DIR, "config.json");
10
+ /**
11
+ * Validate if a string is a valid AgentRole
12
+ */
13
+ function isValidRole(role) {
14
+ return VALID_ROLES.includes(role);
15
+ }
8
16
  // API Key validation - must be at least 16 characters, alphanumeric + common key chars (base64, JWT, etc.)
9
17
  function validateApiKey(key) {
10
18
  if (key.length < 16) {
@@ -43,25 +51,67 @@ export function getConfig() {
43
51
  }
44
52
  export function saveConfig(config) {
45
53
  if (!existsSync(CONFIG_DIR)) {
46
- mkdirSync(CONFIG_DIR, { recursive: true });
54
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); // rwx------ for directory
55
+ }
56
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 }); // rw------- for file
57
+ // Ensure permissions are set even if file existed
58
+ try {
59
+ chmodSync(CONFIG_FILE, 0o600);
60
+ }
61
+ catch {
62
+ // Ignore chmod errors on Windows
47
63
  }
48
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
49
64
  }
50
65
  /**
51
66
  * Fetch role and permissions from /api/auth/whoami
52
- * Caches the result in config for 1 hour
67
+ * Uses session token (Bearer) if available, otherwise falls back to API key.
68
+ * Caches the result in config for 1 hour.
53
69
  */
54
70
  export async function fetchAndCacheRole() {
55
71
  const config = getConfig();
56
- // Check if we have cached role that's less than 1 hour old
72
+ // Check if there's an active session - if so, use session role directly
73
+ // Session roles are already validated by the server, no need to re-fetch
74
+ if (config.sessionToken && config.sessionRole && config.sessionExpiresAt) {
75
+ const expiresAt = new Date(config.sessionExpiresAt);
76
+ if (expiresAt > new Date()) {
77
+ // Session is active - fetch permissions for this session role if needed
78
+ // Check if we have fresh permissions for this session
79
+ const needsPermissionsFetch = !config.roleLastChecked || !config.permissions;
80
+ if (needsPermissionsFetch && config.apiUrl) {
81
+ try {
82
+ const url = new URL("/api/auth/whoami", config.apiUrl);
83
+ const res = await fetch(url.toString(), {
84
+ headers: { Authorization: `Bearer ${config.sessionToken}` },
85
+ });
86
+ if (res.ok) {
87
+ const data = await res.json();
88
+ // Update permissions cache (keep sessionRole as source of truth for role)
89
+ config.permissions = data.permissions;
90
+ config.roleLastChecked = new Date().toISOString();
91
+ saveConfig(config);
92
+ return { role: config.sessionRole, permissions: data.permissions };
93
+ }
94
+ }
95
+ catch {
96
+ // Fall through to use cached permissions
97
+ }
98
+ }
99
+ // Return session role with cached permissions
100
+ return { role: config.sessionRole, permissions: config.permissions };
101
+ }
102
+ }
103
+ // No active session - use API key auth
104
+ // Check if we have cached role that's less than 5 minutes old
105
+ // Short TTL ensures revoked permissions are detected quickly
106
+ const PERMISSION_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
57
107
  if (config.role && config.roleLastChecked) {
58
108
  const lastChecked = new Date(config.roleLastChecked);
59
- const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
60
- if (lastChecked > oneHourAgo) {
109
+ const cacheExpiry = new Date(Date.now() - PERMISSION_CACHE_TTL_MS);
110
+ if (lastChecked > cacheExpiry) {
61
111
  return { role: config.role, permissions: config.permissions };
62
112
  }
63
113
  }
64
- // Fetch fresh role/permissions
114
+ // Fetch fresh role/permissions using API key
65
115
  if (!config.apiUrl || !config.apiKey) {
66
116
  return {};
67
117
  }
@@ -102,10 +152,30 @@ export function hasPermission(permission) {
102
152
  return false;
103
153
  }
104
154
  /**
105
- * Get current role from config (may be undefined if not fetched)
155
+ * Get current role from config.
156
+ * Prefers sessionRole (from auth login) over role (from API key) when session is active.
157
+ * Validates that the role is a known valid role before returning.
106
158
  */
107
159
  export function getRole() {
108
- return getConfig().role;
160
+ const config = getConfig();
161
+ // Check if there's an active (non-expired) session
162
+ if (config.sessionToken && config.sessionRole && config.sessionExpiresAt) {
163
+ const expiresAt = new Date(config.sessionExpiresAt);
164
+ if (expiresAt > new Date()) {
165
+ // Session is active, validate and use session role
166
+ if (isValidRole(config.sessionRole)) {
167
+ return config.sessionRole;
168
+ }
169
+ // Invalid role in session - treat as no session
170
+ console.error(`Warning: Invalid session role '${config.sessionRole}' in config`);
171
+ return undefined;
172
+ }
173
+ }
174
+ // Fall back to API key role (also validate)
175
+ if (config.role && isValidRole(config.role)) {
176
+ return config.role;
177
+ }
178
+ return undefined;
109
179
  }
110
180
  /**
111
181
  * Clear the role cache to force a refresh on next fetchAndCacheRole call
@@ -133,6 +203,17 @@ export function setSessionConfig(session) {
133
203
  config.sessionExpiresAt = session.expiresAt;
134
204
  config.sessionAgent = session.agent;
135
205
  config.sessionRole = session.role;
206
+ // Clear old permissions and role cache to force re-fetch for new session role
207
+ delete config.roleLastChecked;
208
+ if (session.permissions) {
209
+ // If permissions provided, use them directly
210
+ config.permissions = session.permissions;
211
+ config.roleLastChecked = new Date().toISOString();
212
+ }
213
+ else {
214
+ // Clear permissions to force re-fetch
215
+ delete config.permissions;
216
+ }
136
217
  saveConfig(config);
137
218
  }
138
219
  export function clearSessionConfig() {
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Interactive Mode: Auth Module
3
+ *
4
+ * Provides menu-based authentication and API key management.
5
+ */
6
+ export declare function authMenu(): Promise<void>;
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Interactive Mode: Auth Module
3
+ *
4
+ * Provides menu-based authentication and API key management.
5
+ */
6
+ import { select, input, confirm } from "@inquirer/prompts";
7
+ import { getConfig, setConfig, setSessionConfig, clearSessionConfig } from "../config.js";
8
+ import { pressEnterToContinue } from "./utils.js";
9
+ export async function authMenu() {
10
+ console.log("\n AUTH");
11
+ console.log(" " + "-".repeat(50));
12
+ console.log(" API keys and session management");
13
+ console.log("");
14
+ const menuItems = [
15
+ { name: "🔑 Login (Create Session)", value: "login" },
16
+ { name: "📊 Session Status", value: "session" },
17
+ { name: "🔄 Refresh Token", value: "refresh" },
18
+ { name: "🚪 Logout", value: "logout" },
19
+ { name: "🔐 Show API Key", value: "show-key" },
20
+ { name: "✏️ Set API Key", value: "set-key" },
21
+ { name: "← Back", value: "back" },
22
+ ];
23
+ const choice = await select({
24
+ message: "Auth Action:",
25
+ choices: menuItems,
26
+ });
27
+ switch (choice) {
28
+ case "login":
29
+ await login();
30
+ break;
31
+ case "session":
32
+ await sessionStatus();
33
+ break;
34
+ case "refresh":
35
+ await refreshToken();
36
+ break;
37
+ case "logout":
38
+ await logout();
39
+ break;
40
+ case "show-key":
41
+ await showApiKey();
42
+ break;
43
+ case "set-key":
44
+ await setApiKey();
45
+ break;
46
+ case "back":
47
+ return;
48
+ }
49
+ }
50
+ async function login() {
51
+ try {
52
+ const config = getConfig();
53
+ if (!config.apiUrl) {
54
+ console.error("\n Error: API URL not configured. Run 'husky config set api-url <url>' first.\n");
55
+ await pressEnterToContinue();
56
+ return;
57
+ }
58
+ const agentName = await input({
59
+ message: "Agent name:",
60
+ default: "interactive-cli",
61
+ validate: (v) => (v.length > 0 ? true : "Agent name is required"),
62
+ });
63
+ console.log("\n Creating session...");
64
+ const res = await fetch(`${config.apiUrl}/api/auth/session`, {
65
+ method: "POST",
66
+ headers: {
67
+ "Content-Type": "application/json",
68
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
69
+ },
70
+ body: JSON.stringify({ agent: agentName }),
71
+ });
72
+ if (!res.ok) {
73
+ const error = await res.text();
74
+ console.error(`\n Error: ${error}\n`);
75
+ }
76
+ else {
77
+ const data = await res.json();
78
+ // Save session token
79
+ setSessionConfig({
80
+ token: data.token,
81
+ expiresAt: data.expiresAt,
82
+ agent: agentName,
83
+ role: data.role || "",
84
+ });
85
+ console.log("\n Session created!");
86
+ console.log(` Agent: ${agentName}`);
87
+ console.log(` Expires: ${new Date(data.expiresAt).toLocaleString()}`);
88
+ console.log("");
89
+ }
90
+ await pressEnterToContinue();
91
+ }
92
+ catch (error) {
93
+ console.error("\n Error creating session:", error);
94
+ await pressEnterToContinue();
95
+ }
96
+ }
97
+ async function sessionStatus() {
98
+ try {
99
+ const config = getConfig();
100
+ console.log("\n SESSION STATUS");
101
+ console.log(" " + "-".repeat(50));
102
+ if (!config.sessionToken) {
103
+ console.log(" No active session.");
104
+ console.log(" Use 'Login' to create a new session.");
105
+ }
106
+ else {
107
+ const expiresAt = config.sessionExpiresAt ? new Date(config.sessionExpiresAt) : null;
108
+ const isExpired = expiresAt ? expiresAt < new Date() : true;
109
+ console.log(` Agent: ${config.sessionAgent || "unknown"}`);
110
+ console.log(` Token: ${config.sessionToken.substring(0, 20)}...`);
111
+ console.log(` Expires: ${expiresAt ? expiresAt.toLocaleString() : "unknown"}`);
112
+ console.log(` Status: ${isExpired ? "❌ EXPIRED" : "✅ VALID"}`);
113
+ }
114
+ console.log("");
115
+ await pressEnterToContinue();
116
+ }
117
+ catch (error) {
118
+ console.error("\n Error checking session:", error);
119
+ await pressEnterToContinue();
120
+ }
121
+ }
122
+ async function refreshToken() {
123
+ try {
124
+ const config = getConfig();
125
+ if (!config.apiUrl) {
126
+ console.error("\n Error: API URL not configured.\n");
127
+ await pressEnterToContinue();
128
+ return;
129
+ }
130
+ if (!config.sessionToken) {
131
+ console.error("\n Error: No active session to refresh.\n");
132
+ await pressEnterToContinue();
133
+ return;
134
+ }
135
+ console.log("\n Refreshing token...");
136
+ const res = await fetch(`${config.apiUrl}/api/auth/refresh`, {
137
+ method: "POST",
138
+ headers: {
139
+ "Content-Type": "application/json",
140
+ Authorization: `Bearer ${config.sessionToken}`,
141
+ },
142
+ });
143
+ if (!res.ok) {
144
+ const error = await res.text();
145
+ console.error(`\n Error: ${error}\n`);
146
+ }
147
+ else {
148
+ const data = await res.json();
149
+ const currentConfig = getConfig();
150
+ setSessionConfig({
151
+ token: data.token,
152
+ expiresAt: data.expiresAt,
153
+ agent: currentConfig.sessionAgent || "",
154
+ role: data.role || currentConfig.sessionRole || "",
155
+ });
156
+ console.log("\n Token refreshed!");
157
+ console.log(` New expiry: ${new Date(data.expiresAt).toLocaleString()}`);
158
+ console.log("");
159
+ }
160
+ await pressEnterToContinue();
161
+ }
162
+ catch (error) {
163
+ console.error("\n Error refreshing token:", error);
164
+ await pressEnterToContinue();
165
+ }
166
+ }
167
+ async function logout() {
168
+ try {
169
+ const config = getConfig();
170
+ if (!config.sessionToken) {
171
+ console.log("\n No active session.\n");
172
+ await pressEnterToContinue();
173
+ return;
174
+ }
175
+ const confirmed = await confirm({
176
+ message: "Are you sure you want to logout?",
177
+ default: true,
178
+ });
179
+ if (!confirmed) {
180
+ console.log("\n Cancelled.\n");
181
+ await pressEnterToContinue();
182
+ return;
183
+ }
184
+ // Clear session from config
185
+ clearSessionConfig();
186
+ console.log("\n Logged out successfully!\n");
187
+ await pressEnterToContinue();
188
+ }
189
+ catch (error) {
190
+ console.error("\n Error logging out:", error);
191
+ await pressEnterToContinue();
192
+ }
193
+ }
194
+ async function showApiKey() {
195
+ const config = getConfig();
196
+ console.log("\n API KEY");
197
+ console.log(" " + "-".repeat(50));
198
+ if (!config.apiKey) {
199
+ console.log(" No API key configured.");
200
+ }
201
+ else {
202
+ const masked = config.apiKey.substring(0, 8) + "..." + config.apiKey.substring(config.apiKey.length - 4);
203
+ console.log(` API Key: ${masked}`);
204
+ console.log(` Length: ${config.apiKey.length} characters`);
205
+ }
206
+ console.log("");
207
+ await pressEnterToContinue();
208
+ }
209
+ async function setApiKey() {
210
+ try {
211
+ const key = await input({
212
+ message: "New API Key:",
213
+ validate: (v) => {
214
+ if (v.length < 16)
215
+ return "API key must be at least 16 characters";
216
+ return true;
217
+ },
218
+ });
219
+ setConfig("apiKey", key);
220
+ console.log("\n API Key saved!\n");
221
+ await pressEnterToContinue();
222
+ }
223
+ catch (error) {
224
+ console.error("\n Error setting API key:", error);
225
+ await pressEnterToContinue();
226
+ }
227
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Interactive Mode: Brain Module
3
+ *
4
+ * Provides menu-based agent memory and learning management.
5
+ */
6
+ export declare function brainMenu(): Promise<void>;