@simonfestl/husky-cli 1.16.0 → 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() {
@@ -4,7 +4,7 @@
4
4
  * This module provides permission checking for CLI commands.
5
5
  * Permissions are fetched from the API and cached locally.
6
6
  */
7
- export type AgentRole = "admin" | "supervisor" | "worker" | "reviewer" | "e2e_agent" | "pr_agent" | "support" | "devops";
7
+ export type AgentRole = "admin" | "supervisor" | "worker" | "reviewer" | "e2e_agent" | "pr_agent" | "support" | "devops" | "purchasing" | "ops";
8
8
  /**
9
9
  * Check if current user has a specific permission.
10
10
  * Uses cached permissions from config.
@@ -37,8 +37,7 @@ const workerPermissionHints = {
37
37
  * Use this at the start of command handlers to enforce RBAC.
38
38
  */
39
39
  export function requirePermission(permission) {
40
- const config = getConfig();
41
- const role = config.role;
40
+ const role = getRole(); // Uses session role if active, otherwise API key role
42
41
  if (!hasPermission(permission)) {
43
42
  console.error(`Error: Permission denied (${permission})`);
44
43
  if (role) {
@@ -66,11 +65,11 @@ export function requirePermission(permission) {
66
65
  export function requireAnyPermission(permissions) {
67
66
  const hasAny = permissions.some((p) => hasPermission(p));
68
67
  if (!hasAny) {
69
- const config = getConfig();
68
+ const role = getRole(); // Uses session role if active
70
69
  console.error(`Error: Permission denied`);
71
70
  console.error(`Required: one of [${permissions.join(", ")}]`);
72
- if (config.role) {
73
- console.error(`Your role (${config.role}) does not have these permissions.`);
71
+ if (role) {
72
+ console.error(`Your role (${role}) does not have these permissions.`);
74
73
  }
75
74
  console.error(`\nšŸ’” For configuration help: husky explain ${ExplainTopic.CONFIG}`);
76
75
  process.exit(1);
@@ -87,15 +86,14 @@ export function getCurrentRole() {
87
86
  * Check if current agent has one of the specified roles.
88
87
  */
89
88
  export function hasRole(...roles) {
90
- const config = getConfig();
91
- return roles.includes(config.role);
89
+ const currentRole = getRole(); // Uses session role if active
90
+ return currentRole ? roles.includes(currentRole) : false;
92
91
  }
93
92
  /**
94
93
  * Require one of the specified roles, exit if not granted.
95
94
  */
96
95
  export function requireRole(...roles) {
97
- const config = getConfig();
98
- const currentRole = config.role;
96
+ const currentRole = getRole(); // Uses session role if active
99
97
  if (!currentRole || !roles.includes(currentRole)) {
100
98
  console.error(`Error: Role required: ${roles.join(" or ")}`);
101
99
  console.error(`Your role: ${currentRole || "not set"}`);
@@ -133,9 +131,10 @@ export async function handleApiResponse(res, operation) {
133
131
  console.error(`Permission denied for: ${operation}`);
134
132
  console.error("Refreshing permissions...");
135
133
  await refreshPermissions();
134
+ const role = getRole(); // Uses session role if active
136
135
  const config = getConfig();
137
- if (config.role) {
138
- console.error(`Your role (${config.role}) does not have permission for this operation.`);
136
+ if (role) {
137
+ console.error(`Your role (${role}) does not have permission for this operation.`);
139
138
  if (config.permissions) {
140
139
  console.error(`Current permissions: ${config.permissions.join(", ")}`);
141
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonfestl/husky-cli",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "CLI for Huskyv0 Task Orchestration with Claude Agent SDK",
5
5
  "type": "module",
6
6
  "bin": {