@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.
- package/dist/commands/auth.js +25 -2
- package/dist/commands/config.d.ts +8 -3
- package/dist/commands/config.js +91 -10
- package/dist/lib/permissions.d.ts +1 -1
- package/dist/lib/permissions.js +10 -11
- package/package.json +1 -1
package/dist/commands/auth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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(): {
|
package/dist/commands/config.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
60
|
-
if (lastChecked >
|
|
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
|
|
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
|
-
|
|
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.
|
package/dist/lib/permissions.js
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
73
|
-
console.error(`Your role (${
|
|
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
|
|
91
|
-
return roles.includes(
|
|
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
|
|
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 (
|
|
138
|
-
console.error(`Your role (${
|
|
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
|
}
|