@s-gw/s-gw 0.1.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/.codex-plugin/plugin.json +35 -0
- package/.mcp.json +16 -0
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +197 -0
- package/TRADEMARKS.md +9 -0
- package/assets/icons/aws-ec2.png +0 -0
- package/assets/icons/lucide/bot.svg +8 -0
- package/assets/icons/lucide/monitor.svg +5 -0
- package/assets/icons/lucide/server.svg +6 -0
- package/assets/icons/lucide/terminal.svg +4 -0
- package/assets/icons/s-gw-128.png +0 -0
- package/assets/icons/s-gw-16.png +0 -0
- package/assets/icons/s-gw-180.png +0 -0
- package/assets/icons/s-gw-192.png +0 -0
- package/assets/icons/s-gw-32.png +0 -0
- package/assets/icons/s-gw-64.png +0 -0
- package/assets/icons/s-gw-menu-bar-template.png +0 -0
- package/dist/agent-context.d.ts +17 -0
- package/dist/agent-context.js +207 -0
- package/dist/agents.d.ts +64 -0
- package/dist/agents.js +763 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1385 -0
- package/dist/command-suggest.d.ts +3 -0
- package/dist/command-suggest.js +131 -0
- package/dist/console-server.d.ts +16 -0
- package/dist/console-server.js +978 -0
- package/dist/console-ui/assets/codex-DYTPdPxi.png +0 -0
- package/dist/console-ui/assets/cursor-CBrUTJD-.png +0 -0
- package/dist/console-ui/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/console-ui/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/console-ui/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/console-ui/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/console-ui/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/console-ui/assets/hermes-B8hNbJPm.png +0 -0
- package/dist/console-ui/assets/index-BxUf0Sye.js +96 -0
- package/dist/console-ui/assets/index-CmTiBR_w.css +2 -0
- package/dist/console-ui/assets/omnigent-Cxa4p2Mq.png +0 -0
- package/dist/console-ui/assets/openclaw-C5wL4ZVW.png +0 -0
- package/dist/console-ui/assets/opencode-D_wFATSC.png +0 -0
- package/dist/console-ui/assets/openhands-DnrlGgev.svg +9 -0
- package/dist/console-ui/assets/s-gw-64-ByMUGQ3K.png +0 -0
- package/dist/console-ui/assets/vscode-Bdtr9eyf.png +0 -0
- package/dist/console-ui/assets/zeptoclaw-DztQW8Sw.png +0 -0
- package/dist/console-ui/index.html +13 -0
- package/dist/crypto.d.ts +6 -0
- package/dist/crypto.js +53 -0
- package/dist/executor.d.ts +7 -0
- package/dist/executor.js +297 -0
- package/dist/gateway.d.ts +31 -0
- package/dist/gateway.js +114 -0
- package/dist/guard.d.ts +61 -0
- package/dist/guard.js +247 -0
- package/dist/install.d.ts +146 -0
- package/dist/install.js +629 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +119 -0
- package/dist/native/s-gw-core +0 -0
- package/dist/native/s-gw-keychain-helper +0 -0
- package/dist/onepassword.d.ts +48 -0
- package/dist/onepassword.js +412 -0
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +22 -0
- package/dist/s-gw Menu Bar.app/Contents/Info.plist +28 -0
- package/dist/s-gw Menu Bar.app/Contents/MacOS/s-gw-menu-bar-helper +0 -0
- package/dist/s-gw Menu Bar.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/s-gw Menu Bar.app/Contents/Resources/AwsEc2.png +0 -0
- package/dist/s-gw Menu Bar.app/Contents/Resources/Lucide-bot.svg +8 -0
- package/dist/s-gw Menu Bar.app/Contents/Resources/Lucide-monitor.svg +5 -0
- package/dist/s-gw Menu Bar.app/Contents/Resources/Lucide-server.svg +6 -0
- package/dist/s-gw Menu Bar.app/Contents/Resources/Lucide-terminal.svg +4 -0
- package/dist/s-gw Menu Bar.app/Contents/Resources/MenuBarTemplate.png +0 -0
- package/dist/s-gw Menu Bar.app/Contents/_CodeSignature/CodeResources +194 -0
- package/dist/s-gw.app/Contents/Info.plist +28 -0
- package/dist/s-gw.app/Contents/MacOS/s-gw +0 -0
- package/dist/s-gw.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/s-gw.app/Contents/Resources/MenuBarTemplate.png +0 -0
- package/dist/s-gw.app/Contents/_CodeSignature/CodeResources +139 -0
- package/dist/scanner.d.ts +9 -0
- package/dist/scanner.js +437 -0
- package/dist/ssh.d.ts +31 -0
- package/dist/ssh.js +286 -0
- package/dist/store.d.ts +131 -0
- package/dist/store.js +1611 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +2 -0
- package/dist/unlock.d.ts +29 -0
- package/dist/unlock.js +274 -0
- package/dist/windows/VERSION.txt +1 -0
- package/dist/windows/s-gw-client.cmd +4 -0
- package/dist/windows/s-gw-client.ps1 +106 -0
- package/dist/windows/s-gw-credential.cmd +4 -0
- package/dist/windows/s-gw-credential.ps1 +167 -0
- package/dist/windows/s-gw-helper.cmd +4 -0
- package/dist/windows/s-gw-helper.ps1 +180 -0
- package/docs/README.md +23 -0
- package/docs/agents.md +160 -0
- package/docs/architecture.md +72 -0
- package/docs/deployment.md +447 -0
- package/docs/detection.md +44 -0
- package/docs/images/s-gw-overview.png +0 -0
- package/docs/integrations.md +195 -0
- package/docs/keychain.md +39 -0
- package/docs/onepassword.md +84 -0
- package/docs/quickstart.md +104 -0
- package/docs/threat-model.md +100 -0
- package/docs/ui/THIRD_PARTY_NOTICES.md +111 -0
- package/docs/ui/apple-touch-icon.png +0 -0
- package/docs/ui/favicon-32.png +0 -0
- package/docs/ui/local-console.html +4477 -0
- package/docs/ui/vendor/d3-sankey/d3-array.LICENSE.txt +27 -0
- package/docs/ui/vendor/d3-sankey/d3-array.min.js +2 -0
- package/docs/ui/vendor/d3-sankey/d3-path.LICENSE.txt +27 -0
- package/docs/ui/vendor/d3-sankey/d3-path.min.js +2 -0
- package/docs/ui/vendor/d3-sankey/d3-sankey.LICENSE.txt +27 -0
- package/docs/ui/vendor/d3-sankey/d3-sankey.min.js +2 -0
- package/docs/ui/vendor/d3-sankey/d3-shape.LICENSE.txt +27 -0
- package/docs/ui/vendor/d3-sankey/d3-shape.min.js +2 -0
- package/docs/ui/vendor/sankeymatic/LICENSE.txt +17 -0
- package/docs/ui/vendor/sankeymatic/sankey.js +897 -0
- package/package.json +117 -0
- package/skills/s-gw/SKILL.md +19 -0
package/dist/store.js
ADDED
|
@@ -0,0 +1,1611 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, chmod, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { decryptSecret, encryptSecret, fingerprintSecret, shortId } from "./crypto.js";
|
|
7
|
+
import { requestAgentIdentity, requestAgentName } from "./agent-context.js";
|
|
8
|
+
import { normalizeOnePasswordReference, readOnePasswordReference } from "./onepassword.js";
|
|
9
|
+
import { SGW_SSH_SESSION_COMMAND, normalizeSshPort, normalizeSshTarget, sshSessionIdentity } from "./ssh.js";
|
|
10
|
+
import { ensureSgwHome, getSgwHome, getStorePath } from "./paths.js";
|
|
11
|
+
import { defaultSecretKeychainService, deleteMacKeychainItem, getMacKeychainItem, setMacKeychainItem } from "./unlock.js";
|
|
12
|
+
const defaultApprovalSettings = {
|
|
13
|
+
mode: "per-transaction",
|
|
14
|
+
durationMs: 15 * 60 * 1000
|
|
15
|
+
};
|
|
16
|
+
const maxApprovalDurationMs = 30 * 24 * 60 * 60 * 1000;
|
|
17
|
+
const lockTimeoutMs = 5_000;
|
|
18
|
+
const staleLockMs = 30_000;
|
|
19
|
+
// A request gets claimed into "executing" right before its secret is revealed. If the
|
|
20
|
+
// runner is killed, sleeps, or crashes before markExecuted/markFailed, that request is
|
|
21
|
+
// stranded. Anything still "executing" past this window almost certainly lost its runner,
|
|
22
|
+
// so we reap it back to a terminal failed state instead of bricking it forever.
|
|
23
|
+
const staleExecutionMs = 10 * 60 * 1000;
|
|
24
|
+
const maxStoreBackups = 20;
|
|
25
|
+
const emptyStore = () => ({
|
|
26
|
+
version: 1,
|
|
27
|
+
secrets: [],
|
|
28
|
+
requests: [],
|
|
29
|
+
audit: [],
|
|
30
|
+
approvalSettings: { ...defaultApprovalSettings },
|
|
31
|
+
approvalGrants: [],
|
|
32
|
+
approvalPolicyRules: []
|
|
33
|
+
});
|
|
34
|
+
export class SecretStore {
|
|
35
|
+
home;
|
|
36
|
+
storePath;
|
|
37
|
+
constructor(home = getSgwHome()) {
|
|
38
|
+
this.home = home;
|
|
39
|
+
this.storePath = getStorePath(home);
|
|
40
|
+
}
|
|
41
|
+
async init() {
|
|
42
|
+
await ensureSgwHome(this.home);
|
|
43
|
+
await this.withStoreLock(async () => {
|
|
44
|
+
const exists = await this.exists();
|
|
45
|
+
if (exists) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
await this.writeUnlocked(emptyStore());
|
|
49
|
+
await chmod(this.storePath, 0o600);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async addSecret(input) {
|
|
53
|
+
if (!input.value) {
|
|
54
|
+
throw new Error("Cannot add an empty secret.");
|
|
55
|
+
}
|
|
56
|
+
return this.mutate(async (store) => {
|
|
57
|
+
const fingerprint = fingerprintSecret(input.value);
|
|
58
|
+
const now = new Date().toISOString();
|
|
59
|
+
const existing = store.secrets.find((secret) => secret.fingerprint === fingerprint);
|
|
60
|
+
if (existing) {
|
|
61
|
+
existing.updatedAt = now;
|
|
62
|
+
existing.name = input.name || existing.name;
|
|
63
|
+
existing.type = input.type || existing.type;
|
|
64
|
+
existing.provider = input.provider || existing.provider;
|
|
65
|
+
existing.backend = existing.backend || "local";
|
|
66
|
+
existing.ruleId = input.ruleId || existing.ruleId;
|
|
67
|
+
existing.severity = input.severity || existing.severity;
|
|
68
|
+
existing.confidence = input.confidence ?? existing.confidence;
|
|
69
|
+
existing.source = input.source || existing.source;
|
|
70
|
+
existing.policy = normalizePolicy(input.policy, existing.policy);
|
|
71
|
+
store.audit.push(audit("secret.matched", `Existing handle reused for ${existing.name}.`, existing.handle));
|
|
72
|
+
return existing;
|
|
73
|
+
}
|
|
74
|
+
const record = {
|
|
75
|
+
handle: makeHandle(input.type),
|
|
76
|
+
name: input.name,
|
|
77
|
+
type: input.type,
|
|
78
|
+
backend: "local",
|
|
79
|
+
provider: input.provider,
|
|
80
|
+
ruleId: input.ruleId,
|
|
81
|
+
severity: input.severity,
|
|
82
|
+
confidence: input.confidence,
|
|
83
|
+
createdAt: now,
|
|
84
|
+
updatedAt: now,
|
|
85
|
+
source: input.source,
|
|
86
|
+
fingerprint,
|
|
87
|
+
encrypted: encryptSecret(input.value),
|
|
88
|
+
policy: normalizePolicy(input.policy)
|
|
89
|
+
};
|
|
90
|
+
store.secrets.push(record);
|
|
91
|
+
store.audit.push(audit("secret.added", `Added local handle for ${record.name}.`, record.handle));
|
|
92
|
+
return record;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async addKeychainSecret(input) {
|
|
96
|
+
if (!input.value) {
|
|
97
|
+
throw new Error("Cannot add an empty secret.");
|
|
98
|
+
}
|
|
99
|
+
return this.mutate(async (store) => {
|
|
100
|
+
const fingerprint = fingerprintSecret(input.value);
|
|
101
|
+
const now = new Date().toISOString();
|
|
102
|
+
const existing = store.secrets.find((secret) => secret.fingerprint === fingerprint);
|
|
103
|
+
const handle = existing?.handle || makeHandle(input.type);
|
|
104
|
+
const ref = {
|
|
105
|
+
service: input.service || defaultSecretKeychainService(),
|
|
106
|
+
account: handle,
|
|
107
|
+
label: keychainSecretLabel(input.name || existing?.name || handle)
|
|
108
|
+
};
|
|
109
|
+
setMacKeychainItem(ref, input.value);
|
|
110
|
+
const encryptedRef = encryptSecret(JSON.stringify(keychainRefPayload(ref)));
|
|
111
|
+
if (existing) {
|
|
112
|
+
existing.updatedAt = now;
|
|
113
|
+
existing.name = input.name || existing.name;
|
|
114
|
+
existing.type = input.type || existing.type;
|
|
115
|
+
existing.backend = "keychain";
|
|
116
|
+
existing.provider = credentialStoreProvider();
|
|
117
|
+
existing.ruleId = input.ruleId || existing.ruleId;
|
|
118
|
+
existing.severity = input.severity || existing.severity;
|
|
119
|
+
existing.confidence = input.confidence ?? existing.confidence;
|
|
120
|
+
existing.source = input.source || existing.source || credentialStoreProvider();
|
|
121
|
+
existing.encrypted = encryptedRef;
|
|
122
|
+
existing.policy = normalizePolicy(input.policy, existing.policy);
|
|
123
|
+
delete existing.cache;
|
|
124
|
+
store.audit.push(audit("secret.matched", `Existing handle moved to OS credential store for ${existing.name}.`, existing.handle));
|
|
125
|
+
return existing;
|
|
126
|
+
}
|
|
127
|
+
const record = {
|
|
128
|
+
handle,
|
|
129
|
+
name: input.name,
|
|
130
|
+
type: input.type,
|
|
131
|
+
backend: "keychain",
|
|
132
|
+
provider: credentialStoreProvider(),
|
|
133
|
+
ruleId: input.ruleId,
|
|
134
|
+
severity: input.severity,
|
|
135
|
+
confidence: input.confidence,
|
|
136
|
+
createdAt: now,
|
|
137
|
+
updatedAt: now,
|
|
138
|
+
source: input.source || credentialStoreProvider(),
|
|
139
|
+
fingerprint,
|
|
140
|
+
encrypted: encryptedRef,
|
|
141
|
+
policy: normalizePolicy(input.policy)
|
|
142
|
+
};
|
|
143
|
+
store.secrets.push(record);
|
|
144
|
+
store.audit.push(audit("secret.added", `Added OS credential-store-backed handle for ${record.name}.`, record.handle));
|
|
145
|
+
return record;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async addOnePasswordReference(input) {
|
|
149
|
+
const reference = normalizeOnePasswordReference(input.reference);
|
|
150
|
+
return this.mutate(async (store) => {
|
|
151
|
+
const fingerprint = fingerprintSecret(`onepassword:${reference}`);
|
|
152
|
+
const now = new Date().toISOString();
|
|
153
|
+
const existing = store.secrets.find((secret) => secret.fingerprint === fingerprint);
|
|
154
|
+
if (existing) {
|
|
155
|
+
existing.updatedAt = now;
|
|
156
|
+
existing.name = input.name || existing.name;
|
|
157
|
+
existing.type = input.type || existing.type;
|
|
158
|
+
existing.backend = "onepassword";
|
|
159
|
+
existing.provider = "1password";
|
|
160
|
+
existing.source = input.source || existing.source || "onepassword";
|
|
161
|
+
existing.policy = normalizePolicy(input.policy, existing.policy);
|
|
162
|
+
store.audit.push(audit("secret.matched", `Existing 1Password handle reused for ${existing.name}.`, existing.handle));
|
|
163
|
+
return existing;
|
|
164
|
+
}
|
|
165
|
+
const record = {
|
|
166
|
+
handle: makeHandle(input.type),
|
|
167
|
+
name: input.name,
|
|
168
|
+
type: input.type,
|
|
169
|
+
backend: "onepassword",
|
|
170
|
+
provider: "1password",
|
|
171
|
+
severity: "medium",
|
|
172
|
+
confidence: 1,
|
|
173
|
+
createdAt: now,
|
|
174
|
+
updatedAt: now,
|
|
175
|
+
source: input.source || "onepassword",
|
|
176
|
+
fingerprint,
|
|
177
|
+
encrypted: encryptSecret(reference),
|
|
178
|
+
policy: normalizePolicy(input.policy)
|
|
179
|
+
};
|
|
180
|
+
store.secrets.push(record);
|
|
181
|
+
store.audit.push(audit("secret.added", `Added 1Password-backed handle for ${record.name}.`, record.handle));
|
|
182
|
+
return record;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
async listHandles() {
|
|
186
|
+
const store = await this.read();
|
|
187
|
+
return store.secrets.map((secret) => summarizeSecret(secret));
|
|
188
|
+
}
|
|
189
|
+
async getHandle(handle) {
|
|
190
|
+
const store = await this.read();
|
|
191
|
+
const found = store.secrets.find((secret) => secret.handle === handle);
|
|
192
|
+
return found ? summarizeSecret(found) : undefined;
|
|
193
|
+
}
|
|
194
|
+
async allowCommand(handle, command) {
|
|
195
|
+
const trimmed = command.trim();
|
|
196
|
+
if (!trimmed) {
|
|
197
|
+
throw new Error("Command is required.");
|
|
198
|
+
}
|
|
199
|
+
return this.mutate((store) => {
|
|
200
|
+
const secret = store.secrets.find((item) => item.handle === handle);
|
|
201
|
+
if (!secret) {
|
|
202
|
+
throw new Error(`Unknown secret handle: ${handle}`);
|
|
203
|
+
}
|
|
204
|
+
const allowed = new Set(secret.policy.allowedCommands || []);
|
|
205
|
+
allowed.add(trimmed);
|
|
206
|
+
secret.policy = normalizePolicy({ allowedCommands: [...allowed] }, secret.policy);
|
|
207
|
+
secret.updatedAt = new Date().toISOString();
|
|
208
|
+
store.audit.push(audit("secret.policy.updated", `Allowed ${trimmed} for ${secret.name}.`, handle));
|
|
209
|
+
return summarizeSecret(secret);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
async getSecretRecord(handle) {
|
|
213
|
+
const store = await this.read();
|
|
214
|
+
const found = store.secrets.find((secret) => secret.handle === handle);
|
|
215
|
+
if (!found) {
|
|
216
|
+
throw new Error(`Unknown secret handle: ${handle}`);
|
|
217
|
+
}
|
|
218
|
+
return found;
|
|
219
|
+
}
|
|
220
|
+
async revealSecretForLocalUse(handle, request) {
|
|
221
|
+
const record = await this.getSecretRecord(handle);
|
|
222
|
+
if (record.backend === "onepassword") {
|
|
223
|
+
const cached = cachedOnePasswordValue(record, request);
|
|
224
|
+
if (cached) {
|
|
225
|
+
return cached;
|
|
226
|
+
}
|
|
227
|
+
const reference = decryptSecret(record.encrypted);
|
|
228
|
+
const value = await readOnePasswordReference(reference);
|
|
229
|
+
await this.storeOnePasswordCache(handle, value, request);
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
if (record.backend === "keychain") {
|
|
233
|
+
return getMacKeychainItem(keychainRefFromRecord(record));
|
|
234
|
+
}
|
|
235
|
+
return decryptSecret(record.encrypted);
|
|
236
|
+
}
|
|
237
|
+
async storeOnePasswordCache(handle, value, request) {
|
|
238
|
+
if (!request?.approvalGrantId || !value) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
await this.mutate((store) => {
|
|
242
|
+
const now = new Date().toISOString();
|
|
243
|
+
pruneExpiredApprovalGrants(store, now);
|
|
244
|
+
const secret = store.secrets.find((item) => item.handle === handle && item.backend === "onepassword");
|
|
245
|
+
const grant = store.approvalGrants.find((item) => {
|
|
246
|
+
return item.id === request.approvalGrantId && requestReferencesHandle(request, handle);
|
|
247
|
+
});
|
|
248
|
+
if (!secret || !grant || !grantAllowsCache(grant, now)) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const existing = secret.cache;
|
|
252
|
+
secret.cache = {
|
|
253
|
+
backend: "onepassword",
|
|
254
|
+
encrypted: encryptSecret(value),
|
|
255
|
+
fingerprint: fingerprintSecret(`onepassword-cache:${value}`),
|
|
256
|
+
approvalGrantId: grant.id,
|
|
257
|
+
createdAt: existing?.approvalGrantId === grant.id ? existing.createdAt : now,
|
|
258
|
+
updatedAt: now,
|
|
259
|
+
expiresAt: grant.expiresAt,
|
|
260
|
+
loginSessionId: grant.mode === "always" ? undefined : grant.loginSessionId
|
|
261
|
+
};
|
|
262
|
+
secret.updatedAt = now;
|
|
263
|
+
store.audit.push(audit("secret.cache.updated", `Cached 1Password value for ${secret.name}.`, handle, request.id));
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
async deleteSecret(handle) {
|
|
267
|
+
let keychainRef;
|
|
268
|
+
const result = await this.mutate((store) => {
|
|
269
|
+
const index = store.secrets.findIndex((secret) => secret.handle === handle);
|
|
270
|
+
if (index < 0) {
|
|
271
|
+
throw new Error(`Unknown secret handle: ${handle}`);
|
|
272
|
+
}
|
|
273
|
+
const [deleted] = store.secrets.splice(index, 1);
|
|
274
|
+
if (deleted.backend === "keychain") {
|
|
275
|
+
keychainRef = keychainRefFromRecord(deleted);
|
|
276
|
+
}
|
|
277
|
+
const beforeGrants = store.approvalGrants.length;
|
|
278
|
+
const beforePolicies = (store.approvalPolicyRules || []).length;
|
|
279
|
+
const requestsById = new Map(store.requests.map((request) => [request.id, request]));
|
|
280
|
+
store.approvalGrants = store.approvalGrants.filter((grant) => {
|
|
281
|
+
if (grant.handle === handle) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
const request = grant.lastRequestId ? requestsById.get(grant.lastRequestId) : undefined;
|
|
285
|
+
return !request || !requestReferencesHandle(request, handle);
|
|
286
|
+
});
|
|
287
|
+
store.approvalPolicyRules = (store.approvalPolicyRules || []).filter((rule) => {
|
|
288
|
+
return !(rule.conditions.handles || []).includes(handle);
|
|
289
|
+
});
|
|
290
|
+
const now = new Date().toISOString();
|
|
291
|
+
const failedRequests = [];
|
|
292
|
+
for (const request of store.requests) {
|
|
293
|
+
if (!requestReferencesHandle(request, handle) || isTerminalRequestState(request.state)) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
request.state = "failed";
|
|
297
|
+
request.updatedAt = now;
|
|
298
|
+
request.error = "Credential handle was deleted before the request completed.";
|
|
299
|
+
failedRequests.push(request);
|
|
300
|
+
store.audit.push(audit("request.failed", `Request ${request.id} failed because credential ${handle} was deleted.`, handle, request.id));
|
|
301
|
+
}
|
|
302
|
+
store.audit.push(audit("secret.deleted", `Deleted credential handle for ${deleted.name}.`, handle));
|
|
303
|
+
return {
|
|
304
|
+
handle,
|
|
305
|
+
name: deleted.name,
|
|
306
|
+
revokedApprovalGrants: beforeGrants - store.approvalGrants.length,
|
|
307
|
+
revokedApprovalPolicies: beforePolicies - store.approvalPolicyRules.length,
|
|
308
|
+
failedRequests
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
if (keychainRef) {
|
|
312
|
+
deleteMacKeychainItem(keychainRef);
|
|
313
|
+
}
|
|
314
|
+
return result;
|
|
315
|
+
}
|
|
316
|
+
async getApprovalSettings() {
|
|
317
|
+
const store = await this.read();
|
|
318
|
+
return normalizeApprovalSettings(store.approvalSettings);
|
|
319
|
+
}
|
|
320
|
+
async setApprovalSettings(input) {
|
|
321
|
+
return this.mutate((store) => {
|
|
322
|
+
const settings = normalizeApprovalSettings(input);
|
|
323
|
+
store.approvalSettings = settings;
|
|
324
|
+
store.approvalGrants = [];
|
|
325
|
+
clearOnePasswordCaches(store);
|
|
326
|
+
store.audit.push(audit("approval.settings.updated", `Approval mode changed to ${settings.mode}.`));
|
|
327
|
+
return settings;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
async listApprovalGrants() {
|
|
331
|
+
return this.mutate((store) => {
|
|
332
|
+
pruneExpiredApprovalGrants(store, new Date().toISOString());
|
|
333
|
+
return [...store.approvalGrants].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
async revokeApprovalGrant(id) {
|
|
337
|
+
return this.mutate((store) => {
|
|
338
|
+
pruneExpiredApprovalGrants(store, new Date().toISOString());
|
|
339
|
+
const index = store.approvalGrants.findIndex((grant) => grant.id === id);
|
|
340
|
+
if (index < 0) {
|
|
341
|
+
throw new Error(`Unknown approval grant: ${id}`);
|
|
342
|
+
}
|
|
343
|
+
const [revoked] = store.approvalGrants.splice(index, 1);
|
|
344
|
+
clearOnePasswordCaches(store, new Set([revoked.id]));
|
|
345
|
+
store.audit.push(audit("approval.grant.revoked", `Revoked approval grant ${id}.`, revoked.handle, revoked.lastRequestId));
|
|
346
|
+
return revoked;
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
async clearApprovalGrants() {
|
|
350
|
+
return this.mutate((store) => {
|
|
351
|
+
pruneExpiredApprovalGrants(store, new Date().toISOString());
|
|
352
|
+
const revoked = store.approvalGrants;
|
|
353
|
+
store.approvalGrants = [];
|
|
354
|
+
clearOnePasswordCaches(store);
|
|
355
|
+
if (revoked.length > 0) {
|
|
356
|
+
store.audit.push(audit("approval.grants.cleared", `Revoked ${revoked.length} approval grant(s).`));
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
revokedCount: revoked.length,
|
|
360
|
+
revoked
|
|
361
|
+
};
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
async listApprovalPolicyRules() {
|
|
365
|
+
const store = await this.read();
|
|
366
|
+
return sortApprovalPolicyRules(store.approvalPolicyRules || []);
|
|
367
|
+
}
|
|
368
|
+
async addApprovalPolicyRule(input) {
|
|
369
|
+
return this.mutate((store) => {
|
|
370
|
+
const now = new Date().toISOString();
|
|
371
|
+
const rule = normalizeApprovalPolicyRule({
|
|
372
|
+
id: shortId("policy"),
|
|
373
|
+
name: input.name || defaultPolicyRuleName(input.decision),
|
|
374
|
+
enabled: input.enabled !== false,
|
|
375
|
+
priority: input.priority ?? nextPolicyPriority(store),
|
|
376
|
+
decision: input.decision,
|
|
377
|
+
conditions: normalizeApprovalPolicyConditions(input.conditions),
|
|
378
|
+
expiresAt: policyExpiresAt(input, now),
|
|
379
|
+
createdAt: now,
|
|
380
|
+
updatedAt: now
|
|
381
|
+
}, now);
|
|
382
|
+
store.approvalPolicyRules = sortApprovalPolicyRules([...(store.approvalPolicyRules || []), rule]);
|
|
383
|
+
store.audit.push(audit("approval.policy.created", `Created approval policy ${rule.name}.`));
|
|
384
|
+
return rule;
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
async deleteApprovalPolicyRule(id) {
|
|
388
|
+
return this.mutate((store) => {
|
|
389
|
+
const rules = store.approvalPolicyRules || [];
|
|
390
|
+
const index = rules.findIndex((rule) => rule.id === id);
|
|
391
|
+
if (index < 0) {
|
|
392
|
+
throw new Error(`Unknown approval policy: ${id}`);
|
|
393
|
+
}
|
|
394
|
+
const [deleted] = rules.splice(index, 1);
|
|
395
|
+
store.approvalPolicyRules = rules;
|
|
396
|
+
store.audit.push(audit("approval.policy.deleted", `Deleted approval policy ${deleted.name}.`));
|
|
397
|
+
return deleted;
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
async setApprovalPolicyRuleEnabled(id, enabled) {
|
|
401
|
+
return this.mutate((store) => {
|
|
402
|
+
const rule = (store.approvalPolicyRules || []).find((item) => item.id === id);
|
|
403
|
+
if (!rule) {
|
|
404
|
+
throw new Error(`Unknown approval policy: ${id}`);
|
|
405
|
+
}
|
|
406
|
+
rule.enabled = enabled;
|
|
407
|
+
rule.updatedAt = new Date().toISOString();
|
|
408
|
+
store.audit.push(audit("approval.policy.updated", `${enabled ? "Enabled" : "Disabled"} approval policy ${rule.name}.`));
|
|
409
|
+
return { id, enabled };
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
async createRequest(handle, action, reason, agentContext = {}) {
|
|
413
|
+
return this.mutate((store) => {
|
|
414
|
+
const secret = store.secrets.find((item) => item.handle === handle);
|
|
415
|
+
if (!secret) {
|
|
416
|
+
throw new Error(`Unknown secret handle: ${handle}`);
|
|
417
|
+
}
|
|
418
|
+
const now = new Date().toISOString();
|
|
419
|
+
pruneExpiredApprovalGrants(store, now);
|
|
420
|
+
const normalizedAction = normalizeAction(action);
|
|
421
|
+
const agent = requestAgentIdentity(reason, agentContext);
|
|
422
|
+
assertActionAllowed(secret, normalizedAction);
|
|
423
|
+
assertBoundHandlesAllowed(store, handle, normalizedAction);
|
|
424
|
+
const policyRule = matchingApprovalPolicyRule(store, secret, normalizedAction, agent.name, now);
|
|
425
|
+
const deniedByPolicy = policyRule?.decision === "deny";
|
|
426
|
+
const allowedByPolicy = policyRule?.decision === "allow";
|
|
427
|
+
const grant = activeApprovalGrant(store, handle, normalizedAction, agent.name, now);
|
|
428
|
+
const record = {
|
|
429
|
+
id: shortId("req"),
|
|
430
|
+
handle,
|
|
431
|
+
reason: reason || "No reason supplied.",
|
|
432
|
+
agentName: agent.name,
|
|
433
|
+
agentSource: agent.source,
|
|
434
|
+
action: normalizedAction,
|
|
435
|
+
state: deniedByPolicy ? "denied" : (grant || allowedByPolicy ? "approved" : "pending"),
|
|
436
|
+
createdAt: now,
|
|
437
|
+
updatedAt: now,
|
|
438
|
+
approvedAt: grant || allowedByPolicy ? now : undefined,
|
|
439
|
+
approvalGrantId: grant?.id,
|
|
440
|
+
approvalPolicyRuleId: policyRule?.id,
|
|
441
|
+
deniedAt: deniedByPolicy ? now : undefined,
|
|
442
|
+
error: deniedByPolicy ? `Denied by approval policy ${policyRule?.name || policyRule?.id}.` : undefined
|
|
443
|
+
};
|
|
444
|
+
if (grant) {
|
|
445
|
+
grant.lastRequestId = record.id;
|
|
446
|
+
grant.updatedAt = now;
|
|
447
|
+
}
|
|
448
|
+
store.requests.push(record);
|
|
449
|
+
const superseded = record.state === "pending" ? supersedeOlderPendingDuplicates(store, record, now) : [];
|
|
450
|
+
const eventType = deniedByPolicy
|
|
451
|
+
? "request.denied_by_policy"
|
|
452
|
+
: (allowedByPolicy ? "request.auto_approved_by_policy" : (grant ? "request.auto_approved" : "request.created"));
|
|
453
|
+
const message = requestCreationMessage(record, grant, policyRule, superseded.length);
|
|
454
|
+
store.audit.push(audit(eventType, message, handle, record.id));
|
|
455
|
+
return record;
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
async listRequests(stateOrOptions) {
|
|
459
|
+
await this.recoverStaleExecutions();
|
|
460
|
+
const store = await this.read();
|
|
461
|
+
const options = typeof stateOrOptions === "string" ? { state: stateOrOptions } : stateOrOptions || {};
|
|
462
|
+
let requests = sortRequestsForOperators(store.requests);
|
|
463
|
+
if (options.state) {
|
|
464
|
+
requests = requests.filter((request) => request.state === options.state);
|
|
465
|
+
}
|
|
466
|
+
if (options.active) {
|
|
467
|
+
requests = requests.filter((request) => !isTerminalRequestState(request.state));
|
|
468
|
+
}
|
|
469
|
+
const limit = normalizeListLimit(options.limit);
|
|
470
|
+
return limit ? requests.slice(0, limit) : requests;
|
|
471
|
+
}
|
|
472
|
+
async cleanupRequests(options = {}) {
|
|
473
|
+
return this.mutate((store) => {
|
|
474
|
+
const now = new Date().toISOString();
|
|
475
|
+
const cleaned = [];
|
|
476
|
+
if (options.duplicatePending !== false) {
|
|
477
|
+
cleaned.push(...cleanupDuplicatePendingRequests(store, now));
|
|
478
|
+
}
|
|
479
|
+
const pendingMs = options.pendingOlderThanMs ?? 24 * 60 * 60 * 1000;
|
|
480
|
+
const approvedMs = options.approvedOlderThanMs ?? 60 * 60 * 1000;
|
|
481
|
+
cleaned.push(...cleanupOldRequests(store, now, pendingMs, approvedMs));
|
|
482
|
+
return {
|
|
483
|
+
cleanedCount: cleaned.length,
|
|
484
|
+
requests: cleaned
|
|
485
|
+
};
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
async listStoreBackups() {
|
|
489
|
+
return listStoreBackups(this.home);
|
|
490
|
+
}
|
|
491
|
+
async getRequest(id) {
|
|
492
|
+
const store = await this.read();
|
|
493
|
+
const found = store.requests.find((request) => request.id === id);
|
|
494
|
+
if (!found) {
|
|
495
|
+
throw new Error(`Unknown request: ${id}`);
|
|
496
|
+
}
|
|
497
|
+
return found;
|
|
498
|
+
}
|
|
499
|
+
async approveRequest(id, options = {}) {
|
|
500
|
+
return this.updateRequest(id, (request, store) => {
|
|
501
|
+
if (request.state !== "pending") {
|
|
502
|
+
throw new Error(`Only pending requests can be approved. Current state: ${request.state}`);
|
|
503
|
+
}
|
|
504
|
+
const now = new Date().toISOString();
|
|
505
|
+
request.state = "approved";
|
|
506
|
+
request.approvedAt = now;
|
|
507
|
+
request.updatedAt = now;
|
|
508
|
+
const grant = createApprovalGrant(store, request, now, options);
|
|
509
|
+
request.approvalGrantId = grant?.id;
|
|
510
|
+
store.audit.push(audit("request.approved", `Approved execution request ${id}.`, request.handle, id));
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
async denyRequest(id) {
|
|
514
|
+
return this.updateRequest(id, (request, store) => {
|
|
515
|
+
if (request.state !== "pending") {
|
|
516
|
+
throw new Error(`Only pending requests can be denied. Current state: ${request.state}`);
|
|
517
|
+
}
|
|
518
|
+
const now = new Date().toISOString();
|
|
519
|
+
request.state = "denied";
|
|
520
|
+
request.deniedAt = now;
|
|
521
|
+
request.updatedAt = now;
|
|
522
|
+
store.audit.push(audit("request.denied", `Denied execution request ${id}.`, request.handle, id));
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
async claimApprovedRequest(id) {
|
|
526
|
+
return this.mutate((store) => {
|
|
527
|
+
// Reap any abandoned executions first so a previously-stranded request for the same
|
|
528
|
+
// handle does not keep its approval grant alive or confuse the audit trail.
|
|
529
|
+
reapStaleExecutions(store, new Date().toISOString());
|
|
530
|
+
const request = store.requests.find((item) => item.id === id);
|
|
531
|
+
if (!request) {
|
|
532
|
+
throw new Error(`Unknown request: ${id}`);
|
|
533
|
+
}
|
|
534
|
+
if (request.state !== "approved") {
|
|
535
|
+
throw new Error(`Request ${id} is ${request.state}; local approval is required before execution.`);
|
|
536
|
+
}
|
|
537
|
+
request.state = "executing";
|
|
538
|
+
request.updatedAt = new Date().toISOString();
|
|
539
|
+
store.audit.push(audit("request.executing", `Executing approved request ${id}.`, request.handle, id));
|
|
540
|
+
return request;
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Mark requests stranded in "executing" (runner crashed/killed before reporting back) as
|
|
545
|
+
* failed so the store self-heals. Returns the requests that were recovered.
|
|
546
|
+
*/
|
|
547
|
+
async recoverStaleExecutions() {
|
|
548
|
+
const store = await this.read();
|
|
549
|
+
if (!store.requests.some((request) => request.state === "executing")) {
|
|
550
|
+
return [];
|
|
551
|
+
}
|
|
552
|
+
let recovered = [];
|
|
553
|
+
await this.mutate((mutable) => {
|
|
554
|
+
recovered = reapStaleExecutions(mutable, new Date().toISOString());
|
|
555
|
+
});
|
|
556
|
+
return recovered;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Explicit user-driven recovery for requests stuck in "executing". Unlike the time-gated
|
|
560
|
+
* automatic sweep, this fails them immediately — the operator has told us the runner is gone
|
|
561
|
+
* (laptop slept, command was Ctrl-C'd, etc.). Pass a request id to recover just that one;
|
|
562
|
+
* omit it to clear every stranded execution.
|
|
563
|
+
*/
|
|
564
|
+
async forceRecoverExecutions(requestId) {
|
|
565
|
+
return this.mutate((store) => {
|
|
566
|
+
const now = new Date().toISOString();
|
|
567
|
+
const targets = store.requests.filter((request) => {
|
|
568
|
+
if (request.state !== "executing") {
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
return !requestId || request.id === requestId;
|
|
572
|
+
});
|
|
573
|
+
if (requestId && targets.length === 0) {
|
|
574
|
+
// Tell the user why nothing happened instead of silently returning an empty list.
|
|
575
|
+
const found = store.requests.find((request) => request.id === requestId);
|
|
576
|
+
if (!found) {
|
|
577
|
+
throw new Error(`Unknown request: ${requestId}`);
|
|
578
|
+
}
|
|
579
|
+
throw new Error(`Request ${requestId} is ${found.state}, not executing; nothing to recover.`);
|
|
580
|
+
}
|
|
581
|
+
for (const request of targets) {
|
|
582
|
+
request.state = "failed";
|
|
583
|
+
request.updatedAt = now;
|
|
584
|
+
request.error = "Execution was recovered manually before it finished. Create a new request to retry.";
|
|
585
|
+
store.audit.push(audit("request.recovered", `Manually recovered stranded execution request ${request.id}.`, request.handle, request.id));
|
|
586
|
+
}
|
|
587
|
+
return targets;
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
async markExecuted(id, summary) {
|
|
591
|
+
return this.updateRequest(id, (request, store) => {
|
|
592
|
+
if (request.state !== "executing" && request.state !== "approved") {
|
|
593
|
+
throw new Error(`Only approved or executing requests can be marked executed. Current state: ${request.state}`);
|
|
594
|
+
}
|
|
595
|
+
const now = new Date().toISOString();
|
|
596
|
+
request.state = "executed";
|
|
597
|
+
request.executedAt = now;
|
|
598
|
+
request.updatedAt = now;
|
|
599
|
+
request.resultSummary = summary;
|
|
600
|
+
request.error = undefined;
|
|
601
|
+
store.audit.push(audit("request.executed", `Executed request ${id}.`, request.handle, id));
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
async markFailed(id, errorMessage) {
|
|
605
|
+
return this.updateRequest(id, (request, store) => {
|
|
606
|
+
if (request.state !== "executing" && request.state !== "approved") {
|
|
607
|
+
throw new Error(`Only approved or executing requests can be marked failed. Current state: ${request.state}`);
|
|
608
|
+
}
|
|
609
|
+
request.state = "failed";
|
|
610
|
+
request.updatedAt = new Date().toISOString();
|
|
611
|
+
request.error = errorMessage;
|
|
612
|
+
store.audit.push(audit("request.failed", `Request ${id} failed: ${errorMessage}`, request.handle, id));
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
async auditLog() {
|
|
616
|
+
const store = await this.read();
|
|
617
|
+
return store.audit;
|
|
618
|
+
}
|
|
619
|
+
async updateRequest(id, updater) {
|
|
620
|
+
return this.mutate((store) => {
|
|
621
|
+
const found = store.requests.find((request) => request.id === id);
|
|
622
|
+
if (!found) {
|
|
623
|
+
throw new Error(`Unknown request: ${id}`);
|
|
624
|
+
}
|
|
625
|
+
updater(found, store);
|
|
626
|
+
return found;
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
async exists() {
|
|
630
|
+
try {
|
|
631
|
+
await access(this.storePath, constants.F_OK);
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
async read() {
|
|
639
|
+
const exists = await this.exists();
|
|
640
|
+
if (!exists) {
|
|
641
|
+
await this.init();
|
|
642
|
+
}
|
|
643
|
+
return this.readUnlocked();
|
|
644
|
+
}
|
|
645
|
+
async readUnlocked() {
|
|
646
|
+
const raw = await readFile(this.storePath, "utf8");
|
|
647
|
+
const parsed = JSON.parse(raw);
|
|
648
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.secrets) || !Array.isArray(parsed.requests)) {
|
|
649
|
+
throw new Error(`Invalid s-gw store at ${this.storePath}`);
|
|
650
|
+
}
|
|
651
|
+
return normalizeStoreFile(parsed);
|
|
652
|
+
}
|
|
653
|
+
async writeUnlocked(store) {
|
|
654
|
+
await ensureSgwHome(this.home);
|
|
655
|
+
await backupCurrentStore(this.home, this.storePath);
|
|
656
|
+
const tmpPath = path.join(this.home, `.store.${process.pid}.${Date.now()}.tmp`);
|
|
657
|
+
await writeFile(tmpPath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
|
658
|
+
await rename(tmpPath, this.storePath);
|
|
659
|
+
await chmod(this.storePath, 0o600);
|
|
660
|
+
}
|
|
661
|
+
async mutate(updater) {
|
|
662
|
+
await ensureSgwHome(this.home);
|
|
663
|
+
return this.withStoreLock(async () => {
|
|
664
|
+
if (!(await this.exists())) {
|
|
665
|
+
await this.writeUnlocked(emptyStore());
|
|
666
|
+
}
|
|
667
|
+
const store = await this.readUnlocked();
|
|
668
|
+
const result = await updater(store);
|
|
669
|
+
await this.writeUnlocked(store);
|
|
670
|
+
return result;
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
async withStoreLock(body) {
|
|
674
|
+
const lockPath = `${this.storePath}.lock`;
|
|
675
|
+
const started = Date.now();
|
|
676
|
+
let lockHandle;
|
|
677
|
+
while (!lockHandle) {
|
|
678
|
+
try {
|
|
679
|
+
lockHandle = await open(lockPath, "wx", 0o600);
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
if (!isNodeError(error) || error.code !== "EEXIST") {
|
|
683
|
+
throw error;
|
|
684
|
+
}
|
|
685
|
+
await removeStaleLock(lockPath);
|
|
686
|
+
if (Date.now() - started > lockTimeoutMs) {
|
|
687
|
+
throw new Error(`Timed out waiting for s-gw store lock at ${lockPath}.`);
|
|
688
|
+
}
|
|
689
|
+
await sleep(25);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
try {
|
|
693
|
+
return await body();
|
|
694
|
+
}
|
|
695
|
+
finally {
|
|
696
|
+
await lockHandle.close().catch(() => undefined);
|
|
697
|
+
await unlink(lockPath).catch(() => undefined);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
async function backupCurrentStore(home, storePath) {
|
|
702
|
+
let current = "";
|
|
703
|
+
try {
|
|
704
|
+
current = await readFile(storePath, "utf8");
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (!current.trim()) {
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const backupDir = path.join(home, "backups");
|
|
713
|
+
await mkdir(backupDir, { recursive: true, mode: 0o700 });
|
|
714
|
+
const stamp = new Date().toISOString().replace(/[-:.]/g, "").slice(0, 15);
|
|
715
|
+
const backupPath = path.join(backupDir, `store-${stamp}-${process.pid}-${Date.now()}.json`);
|
|
716
|
+
await writeFile(backupPath, current, { mode: 0o600 });
|
|
717
|
+
await pruneStoreBackups(backupDir);
|
|
718
|
+
}
|
|
719
|
+
async function listStoreBackups(home) {
|
|
720
|
+
const backupDir = path.join(home, "backups");
|
|
721
|
+
const entries = await readdir(backupDir).catch(() => []);
|
|
722
|
+
const backups = [];
|
|
723
|
+
for (const entry of entries) {
|
|
724
|
+
if (!/^store-\d{8}T\d{6}-\d+-\d+\.json$/.test(entry)) {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const backupPath = path.join(backupDir, entry);
|
|
728
|
+
const info = await stat(backupPath).catch(() => undefined);
|
|
729
|
+
if (!info?.isFile()) {
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
backups.push({
|
|
733
|
+
path: backupPath,
|
|
734
|
+
bytes: info.size,
|
|
735
|
+
modifiedAt: info.mtime.toISOString()
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
return backups.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
|
739
|
+
}
|
|
740
|
+
async function pruneStoreBackups(backupDir) {
|
|
741
|
+
const backups = await listStoreBackups(path.dirname(backupDir));
|
|
742
|
+
for (const backup of backups.slice(maxStoreBackups)) {
|
|
743
|
+
await rm(backup.path, { force: true }).catch(() => undefined);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
function normalizeStoreFile(parsed) {
|
|
747
|
+
return {
|
|
748
|
+
version: 1,
|
|
749
|
+
secrets: (parsed.secrets || []).map((secret) => normalizeSecretRecord(secret)),
|
|
750
|
+
requests: parsed.requests || [],
|
|
751
|
+
audit: parsed.audit || [],
|
|
752
|
+
approvalSettings: normalizeApprovalSettings(parsed.approvalSettings),
|
|
753
|
+
approvalGrants: Array.isArray(parsed.approvalGrants)
|
|
754
|
+
? parsed.approvalGrants.filter(isValidApprovalGrant)
|
|
755
|
+
: [],
|
|
756
|
+
approvalPolicyRules: Array.isArray(parsed.approvalPolicyRules)
|
|
757
|
+
? sortApprovalPolicyRules(parsed.approvalPolicyRules.map((rule) => normalizeApprovalPolicyRule(rule)).filter(isValidApprovalPolicyRule))
|
|
758
|
+
: []
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
function normalizeSecretRecord(secret) {
|
|
762
|
+
if (!secret.cache || isValidSecretCache(secret.cache)) {
|
|
763
|
+
return secret;
|
|
764
|
+
}
|
|
765
|
+
const clone = { ...secret };
|
|
766
|
+
delete clone.cache;
|
|
767
|
+
return clone;
|
|
768
|
+
}
|
|
769
|
+
function keychainRefPayload(ref) {
|
|
770
|
+
return {
|
|
771
|
+
service: ref.service,
|
|
772
|
+
account: ref.account
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
function keychainRefFromRecord(record) {
|
|
776
|
+
try {
|
|
777
|
+
const decoded = JSON.parse(decryptSecret(record.encrypted));
|
|
778
|
+
if (typeof decoded.service === "string" && typeof decoded.account === "string") {
|
|
779
|
+
return {
|
|
780
|
+
service: decoded.service,
|
|
781
|
+
account: decoded.account,
|
|
782
|
+
label: keychainSecretLabel(record.name)
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
// Older experimental records may only have used the handle as account.
|
|
788
|
+
}
|
|
789
|
+
return {
|
|
790
|
+
service: defaultSecretKeychainService(),
|
|
791
|
+
account: record.handle,
|
|
792
|
+
label: keychainSecretLabel(record.name)
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
function keychainSecretLabel(name) {
|
|
796
|
+
const trimmed = name.trim();
|
|
797
|
+
return `s-gw secret: ${trimmed || "credential"}`.slice(0, 128);
|
|
798
|
+
}
|
|
799
|
+
function credentialStoreProvider() {
|
|
800
|
+
return process.platform === "win32" ? "windows-credential-manager" : "macos-keychain";
|
|
801
|
+
}
|
|
802
|
+
function normalizeApprovalSettings(input) {
|
|
803
|
+
const mode = isApprovalMode(input?.mode) ? input.mode : defaultApprovalSettings.mode;
|
|
804
|
+
return {
|
|
805
|
+
mode,
|
|
806
|
+
durationMs: clampApprovalDuration(input?.durationMs)
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
function isApprovalMode(value) {
|
|
810
|
+
return value === "per-transaction" || value === "timed-session" || value === "login-session" || value === "always";
|
|
811
|
+
}
|
|
812
|
+
function isApprovalAgentScope(value) {
|
|
813
|
+
return value === "same-agent" || value === "any-agent";
|
|
814
|
+
}
|
|
815
|
+
function clampApprovalDuration(value) {
|
|
816
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
817
|
+
return defaultApprovalSettings.durationMs;
|
|
818
|
+
}
|
|
819
|
+
return Math.min(Math.max(Math.floor(value), 60_000), maxApprovalDurationMs);
|
|
820
|
+
}
|
|
821
|
+
function isValidApprovalGrant(grant) {
|
|
822
|
+
return (Boolean(grant) &&
|
|
823
|
+
typeof grant.id === "string" &&
|
|
824
|
+
typeof grant.handle === "string" &&
|
|
825
|
+
typeof grant.actionKey === "string" &&
|
|
826
|
+
(grant.mode === "timed-session" || grant.mode === "login-session" || grant.mode === "always") &&
|
|
827
|
+
typeof grant.loginSessionId === "string");
|
|
828
|
+
}
|
|
829
|
+
function isValidSecretCache(cache) {
|
|
830
|
+
return (Boolean(cache) &&
|
|
831
|
+
cache.backend === "onepassword" &&
|
|
832
|
+
typeof cache.fingerprint === "string" &&
|
|
833
|
+
typeof cache.approvalGrantId === "string" &&
|
|
834
|
+
typeof cache.createdAt === "string" &&
|
|
835
|
+
typeof cache.updatedAt === "string" &&
|
|
836
|
+
isEncryptedBox(cache.encrypted));
|
|
837
|
+
}
|
|
838
|
+
function isEncryptedBox(value) {
|
|
839
|
+
if (!value || typeof value !== "object") {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
const box = value;
|
|
843
|
+
return (box.alg === "aes-256-gcm" &&
|
|
844
|
+
box.kdf === "scrypt" &&
|
|
845
|
+
typeof box.salt === "string" &&
|
|
846
|
+
typeof box.iv === "string" &&
|
|
847
|
+
typeof box.authTag === "string" &&
|
|
848
|
+
typeof box.ciphertext === "string");
|
|
849
|
+
}
|
|
850
|
+
function isTerminalRequestState(state) {
|
|
851
|
+
return state === "denied" || state === "executed" || state === "failed";
|
|
852
|
+
}
|
|
853
|
+
function cachedOnePasswordValue(record, request) {
|
|
854
|
+
const cache = record.cache;
|
|
855
|
+
if (record.backend !== "onepassword" || !cache || !request?.approvalGrantId) {
|
|
856
|
+
return undefined;
|
|
857
|
+
}
|
|
858
|
+
const now = new Date().toISOString();
|
|
859
|
+
if (!isValidSecretCache(cache) || cache.approvalGrantId !== request.approvalGrantId) {
|
|
860
|
+
return undefined;
|
|
861
|
+
}
|
|
862
|
+
if (cache.expiresAt && cache.expiresAt <= now) {
|
|
863
|
+
return undefined;
|
|
864
|
+
}
|
|
865
|
+
if (cache.loginSessionId && cache.loginSessionId !== currentLoginSessionId()) {
|
|
866
|
+
return undefined;
|
|
867
|
+
}
|
|
868
|
+
return decryptSecret(cache.encrypted);
|
|
869
|
+
}
|
|
870
|
+
function grantAllowsCache(grant, nowIso) {
|
|
871
|
+
if (!isValidApprovalGrant(grant)) {
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
if (grant.expiresAt && grant.expiresAt <= nowIso) {
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
return grant.mode === "always" || grant.loginSessionId === currentLoginSessionId();
|
|
878
|
+
}
|
|
879
|
+
function clearOnePasswordCaches(store, grantIds) {
|
|
880
|
+
for (const secret of store.secrets) {
|
|
881
|
+
if (!secret.cache) {
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
if (!grantIds || grantIds.has(secret.cache.approvalGrantId)) {
|
|
885
|
+
delete secret.cache;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
function activeApprovalGrant(store, handle, action, agentName, nowIso) {
|
|
890
|
+
const settings = normalizeApprovalSettings(store.approvalSettings);
|
|
891
|
+
store.approvalSettings = settings;
|
|
892
|
+
const actionKey = approvalActionKey(handle, action);
|
|
893
|
+
const loginSessionId = currentLoginSessionId();
|
|
894
|
+
return store.approvalGrants.find((grant) => {
|
|
895
|
+
if (grant.handle !== handle || grant.actionKey !== actionKey) {
|
|
896
|
+
return false;
|
|
897
|
+
}
|
|
898
|
+
if (grant.mode !== "always" && grant.loginSessionId !== loginSessionId) {
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
if ((grant.agentScope || "same-agent") === "same-agent" && grant.agentName !== agentName) {
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
return !grant.expiresAt || grant.expiresAt > nowIso;
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
function createApprovalGrant(store, request, nowIso, options = {}) {
|
|
908
|
+
const settings = normalizeApprovalSettings(store.approvalSettings);
|
|
909
|
+
store.approvalSettings = settings;
|
|
910
|
+
const mode = options.mode && isApprovalMode(options.mode) ? options.mode : settings.mode;
|
|
911
|
+
if (mode === "per-transaction") {
|
|
912
|
+
return undefined;
|
|
913
|
+
}
|
|
914
|
+
pruneExpiredApprovalGrants(store, nowIso);
|
|
915
|
+
const loginSessionId = currentLoginSessionId();
|
|
916
|
+
const actionKey = approvalActionKey(request.handle, request.action);
|
|
917
|
+
const durationMs = clampApprovalDuration(options.durationMs ?? settings.durationMs);
|
|
918
|
+
const expiresAt = mode === "timed-session"
|
|
919
|
+
? new Date(Date.parse(nowIso) + durationMs).toISOString()
|
|
920
|
+
: undefined;
|
|
921
|
+
const agentScope = isApprovalAgentScope(options.agentScope) ? options.agentScope : "same-agent";
|
|
922
|
+
const agentName = request.agentName || requestAgentName(request.reason);
|
|
923
|
+
const existing = store.approvalGrants.find((grant) => {
|
|
924
|
+
return (grant.mode === mode &&
|
|
925
|
+
grant.handle === request.handle &&
|
|
926
|
+
grant.actionKey === actionKey &&
|
|
927
|
+
grant.loginSessionId === loginSessionId &&
|
|
928
|
+
(grant.agentScope || "same-agent") === agentScope &&
|
|
929
|
+
(agentScope === "any-agent" || grant.agentName === agentName));
|
|
930
|
+
});
|
|
931
|
+
if (existing) {
|
|
932
|
+
existing.updatedAt = nowIso;
|
|
933
|
+
existing.expiresAt = expiresAt;
|
|
934
|
+
existing.lastRequestId = request.id;
|
|
935
|
+
store.audit.push(audit("approval.grant.updated", `Updated approval grant ${existing.id}.`, request.handle, request.id));
|
|
936
|
+
return existing;
|
|
937
|
+
}
|
|
938
|
+
const grant = {
|
|
939
|
+
id: shortId("grant"),
|
|
940
|
+
handle: request.handle,
|
|
941
|
+
actionKey,
|
|
942
|
+
mode,
|
|
943
|
+
agentScope,
|
|
944
|
+
agentName: agentScope === "same-agent" ? agentName : undefined,
|
|
945
|
+
loginSessionId,
|
|
946
|
+
createdAt: nowIso,
|
|
947
|
+
updatedAt: nowIso,
|
|
948
|
+
expiresAt,
|
|
949
|
+
lastRequestId: request.id
|
|
950
|
+
};
|
|
951
|
+
store.approvalGrants.push(grant);
|
|
952
|
+
store.audit.push(audit("approval.grant.created", `Created approval grant ${grant.id}.`, request.handle, request.id));
|
|
953
|
+
return grant;
|
|
954
|
+
}
|
|
955
|
+
function requestReferencesHandle(request, handle) {
|
|
956
|
+
return request.handle === handle || (request.action.env || []).some((binding) => binding.handle === handle);
|
|
957
|
+
}
|
|
958
|
+
function requestDuplicateKey(request) {
|
|
959
|
+
return JSON.stringify({
|
|
960
|
+
handle: request.handle,
|
|
961
|
+
actionKey: approvalActionKey(request.handle, request.action),
|
|
962
|
+
agentName: request.agentName || requestAgentName(request.reason)
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
function supersedeOlderPendingDuplicates(store, latest, nowIso) {
|
|
966
|
+
const latestKey = requestDuplicateKey(latest);
|
|
967
|
+
const superseded = [];
|
|
968
|
+
for (const request of store.requests) {
|
|
969
|
+
if (request.id === latest.id || request.state !== "pending") {
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
if (requestDuplicateKey(request) !== latestKey) {
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
request.state = "failed";
|
|
976
|
+
request.updatedAt = nowIso;
|
|
977
|
+
request.error = `Superseded by newer duplicate pending request ${latest.id}.`;
|
|
978
|
+
superseded.push(request);
|
|
979
|
+
store.audit.push(audit("request.superseded", `Request ${request.id} was superseded by newer duplicate ${latest.id}.`, request.handle, request.id));
|
|
980
|
+
}
|
|
981
|
+
return superseded;
|
|
982
|
+
}
|
|
983
|
+
function cleanupDuplicatePendingRequests(store, nowIso) {
|
|
984
|
+
const newestByKey = new Map();
|
|
985
|
+
const pending = store.requests
|
|
986
|
+
.filter((request) => request.state === "pending")
|
|
987
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
988
|
+
const cleaned = [];
|
|
989
|
+
for (const request of pending) {
|
|
990
|
+
const key = requestDuplicateKey(request);
|
|
991
|
+
const newest = newestByKey.get(key);
|
|
992
|
+
if (!newest) {
|
|
993
|
+
newestByKey.set(key, request);
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
request.state = "failed";
|
|
997
|
+
request.updatedAt = nowIso;
|
|
998
|
+
request.error = `Superseded by newer duplicate pending request ${newest.id}.`;
|
|
999
|
+
cleaned.push(request);
|
|
1000
|
+
store.audit.push(audit("request.superseded", `Cleaned duplicate pending request ${request.id}; newest is ${newest.id}.`, request.handle, request.id));
|
|
1001
|
+
}
|
|
1002
|
+
return cleaned;
|
|
1003
|
+
}
|
|
1004
|
+
function cleanupOldRequests(store, nowIso, pendingOlderThanMs, approvedOlderThanMs) {
|
|
1005
|
+
const now = Date.parse(nowIso);
|
|
1006
|
+
const cleaned = [];
|
|
1007
|
+
for (const request of store.requests) {
|
|
1008
|
+
if (request.state !== "pending" && request.state !== "approved") {
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
const timestamp = request.state === "approved" && request.approvedAt
|
|
1012
|
+
? Date.parse(request.approvedAt)
|
|
1013
|
+
: Date.parse(request.createdAt);
|
|
1014
|
+
if (!Number.isFinite(timestamp)) {
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
const ttl = request.state === "approved" ? approvedOlderThanMs : pendingOlderThanMs;
|
|
1018
|
+
if (!Number.isFinite(ttl) || ttl <= 0 || now - timestamp < ttl) {
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
const oldState = request.state;
|
|
1022
|
+
request.state = "failed";
|
|
1023
|
+
request.updatedAt = nowIso;
|
|
1024
|
+
request.error = oldState === "approved"
|
|
1025
|
+
? "Approved request expired before execution. Create a fresh request to retry."
|
|
1026
|
+
: "Pending request expired before approval. Create a fresh request to retry.";
|
|
1027
|
+
cleaned.push(request);
|
|
1028
|
+
store.audit.push(audit("request.expired", `Cleaned stale ${oldState} request ${request.id}.`, request.handle, request.id));
|
|
1029
|
+
}
|
|
1030
|
+
return cleaned;
|
|
1031
|
+
}
|
|
1032
|
+
function sortRequestsForOperators(requests) {
|
|
1033
|
+
const rank = {
|
|
1034
|
+
pending: 0,
|
|
1035
|
+
approved: 1,
|
|
1036
|
+
executing: 2,
|
|
1037
|
+
failed: 3,
|
|
1038
|
+
denied: 4,
|
|
1039
|
+
executed: 5
|
|
1040
|
+
};
|
|
1041
|
+
return [...requests].sort((a, b) => {
|
|
1042
|
+
const rankOrder = rank[a.state] - rank[b.state];
|
|
1043
|
+
return rankOrder || b.updatedAt.localeCompare(a.updatedAt);
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
function normalizeListLimit(value) {
|
|
1047
|
+
if (value === undefined) {
|
|
1048
|
+
return undefined;
|
|
1049
|
+
}
|
|
1050
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1051
|
+
return undefined;
|
|
1052
|
+
}
|
|
1053
|
+
return Math.min(Math.floor(value), 1000);
|
|
1054
|
+
}
|
|
1055
|
+
function reapStaleExecutions(store, nowIso) {
|
|
1056
|
+
const cutoff = Date.parse(nowIso) - staleExecutionMs;
|
|
1057
|
+
const recovered = [];
|
|
1058
|
+
for (const request of store.requests) {
|
|
1059
|
+
if (request.state !== "executing") {
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
const claimedAt = Date.parse(request.updatedAt);
|
|
1063
|
+
// Leave a NaN timestamp alone rather than failing a request we cannot reason about.
|
|
1064
|
+
if (Number.isFinite(claimedAt) && claimedAt > cutoff) {
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
request.state = "failed";
|
|
1068
|
+
request.updatedAt = nowIso;
|
|
1069
|
+
request.error = "Execution was interrupted before it finished. Create a new request to retry.";
|
|
1070
|
+
store.audit.push(audit("request.recovered", `Recovered stranded execution request ${request.id}.`, request.handle, request.id));
|
|
1071
|
+
recovered.push(request);
|
|
1072
|
+
}
|
|
1073
|
+
return recovered;
|
|
1074
|
+
}
|
|
1075
|
+
function pruneExpiredApprovalGrants(store, nowIso) {
|
|
1076
|
+
migrateApprovalGrantActionKeys(store);
|
|
1077
|
+
const active = (store.approvalGrants || []).filter((grant) => {
|
|
1078
|
+
return isValidApprovalGrant(grant) && (!grant.expiresAt || grant.expiresAt > nowIso);
|
|
1079
|
+
});
|
|
1080
|
+
const byIdentity = new Map();
|
|
1081
|
+
for (const grant of active) {
|
|
1082
|
+
const key = approvalGrantIdentityKey(grant);
|
|
1083
|
+
const existing = byIdentity.get(key);
|
|
1084
|
+
if (!existing || approvalGrantIsNewer(grant, existing)) {
|
|
1085
|
+
byIdentity.set(key, grant);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
store.approvalGrants = [...byIdentity.values()];
|
|
1089
|
+
pruneOnePasswordCaches(store, nowIso);
|
|
1090
|
+
}
|
|
1091
|
+
function pruneOnePasswordCaches(store, nowIso) {
|
|
1092
|
+
const liveGrantIds = new Set(store.approvalGrants
|
|
1093
|
+
.filter((grant) => grantAllowsCache(grant, nowIso))
|
|
1094
|
+
.map((grant) => grant.id));
|
|
1095
|
+
for (const secret of store.secrets) {
|
|
1096
|
+
const cache = secret.cache;
|
|
1097
|
+
if (!cache) {
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
if (!isValidSecretCache(cache) ||
|
|
1101
|
+
!liveGrantIds.has(cache.approvalGrantId) ||
|
|
1102
|
+
(cache.expiresAt && cache.expiresAt <= nowIso) ||
|
|
1103
|
+
(cache.loginSessionId && cache.loginSessionId !== currentLoginSessionId())) {
|
|
1104
|
+
delete secret.cache;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
function migrateApprovalGrantActionKeys(store) {
|
|
1109
|
+
const requestsById = new Map(store.requests.map((request) => [request.id, request]));
|
|
1110
|
+
for (const grant of store.approvalGrants || []) {
|
|
1111
|
+
if (!isValidApprovalGrant(grant) || !grant.lastRequestId) {
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
const request = requestsById.get(grant.lastRequestId);
|
|
1115
|
+
if (!request || request.handle !== grant.handle) {
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
grant.actionKey = approvalActionKey(request.handle, request.action);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
function approvalGrantIdentityKey(grant) {
|
|
1122
|
+
return JSON.stringify({
|
|
1123
|
+
handle: grant.handle,
|
|
1124
|
+
actionKey: grant.actionKey,
|
|
1125
|
+
mode: grant.mode,
|
|
1126
|
+
agentScope: grant.agentScope || "same-agent",
|
|
1127
|
+
agentName: grant.agentScope === "any-agent" ? "" : grant.agentName || "",
|
|
1128
|
+
loginSessionId: grant.mode === "always" ? "" : grant.loginSessionId
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
function approvalGrantIsNewer(candidate, current) {
|
|
1132
|
+
const candidateExpires = candidate.expiresAt ? Date.parse(candidate.expiresAt) : Number.POSITIVE_INFINITY;
|
|
1133
|
+
const currentExpires = current.expiresAt ? Date.parse(current.expiresAt) : Number.POSITIVE_INFINITY;
|
|
1134
|
+
if (candidateExpires !== currentExpires) {
|
|
1135
|
+
return candidateExpires > currentExpires;
|
|
1136
|
+
}
|
|
1137
|
+
return candidate.updatedAt > current.updatedAt;
|
|
1138
|
+
}
|
|
1139
|
+
function approvalActionKey(handle, action) {
|
|
1140
|
+
const normalized = normalizeAction(action);
|
|
1141
|
+
const payload = {
|
|
1142
|
+
handle,
|
|
1143
|
+
kind: normalized.kind,
|
|
1144
|
+
command: normalizeCommandGrant(normalized.command),
|
|
1145
|
+
injectEnv: normalized.injectEnv,
|
|
1146
|
+
env: normalized.env || [],
|
|
1147
|
+
workingDir: normalized.workingDir ? path.resolve(normalized.workingDir) : "",
|
|
1148
|
+
ssh: normalized.kind === "ssh_session" && normalized.ssh ? sshSessionIdentity(normalized) : ""
|
|
1149
|
+
};
|
|
1150
|
+
return createHash("sha256").update(JSON.stringify(payload)).digest("base64url");
|
|
1151
|
+
}
|
|
1152
|
+
function matchingApprovalPolicyRule(store, secret, action, agentName, nowIso) {
|
|
1153
|
+
const rules = sortApprovalPolicyRules(store.approvalPolicyRules || []);
|
|
1154
|
+
for (const rule of rules) {
|
|
1155
|
+
if (!rule.enabled || !isValidApprovalPolicyRule(rule)) {
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
if (rule.expiresAt && rule.expiresAt <= nowIso) {
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
if (approvalPolicyMatches(rule, secret, action, agentName)) {
|
|
1162
|
+
return rule;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
return undefined;
|
|
1166
|
+
}
|
|
1167
|
+
function approvalPolicyMatches(rule, secret, action, agentName) {
|
|
1168
|
+
const conditions = normalizeApprovalPolicyConditions(rule.conditions);
|
|
1169
|
+
if (conditions.handles?.length && !conditions.handles.includes(secret.handle)) {
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
if (conditions.secretTypes?.length && !conditions.secretTypes.includes(secret.type)) {
|
|
1173
|
+
return false;
|
|
1174
|
+
}
|
|
1175
|
+
if (conditions.providers?.length && !conditions.providers.includes((secret.provider || "").toLowerCase())) {
|
|
1176
|
+
return false;
|
|
1177
|
+
}
|
|
1178
|
+
if (conditions.minSeverity && severityRank(secret.severity || "low") < severityRank(conditions.minSeverity)) {
|
|
1179
|
+
return false;
|
|
1180
|
+
}
|
|
1181
|
+
if (conditions.agents?.length && !conditions.agents.includes(agentName.toLowerCase())) {
|
|
1182
|
+
return false;
|
|
1183
|
+
}
|
|
1184
|
+
if (conditions.actionKinds?.length && !conditions.actionKinds.includes(action.kind)) {
|
|
1185
|
+
return false;
|
|
1186
|
+
}
|
|
1187
|
+
if (conditions.commands?.length) {
|
|
1188
|
+
const requested = normalizeCommandGrant(action.command);
|
|
1189
|
+
if (!conditions.commands.includes(requested)) {
|
|
1190
|
+
return false;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
if (conditions.injectEnvs?.length) {
|
|
1194
|
+
const names = new Set([action.injectEnv, ...(action.env || []).map((binding) => binding.injectEnv)]);
|
|
1195
|
+
if (![...names].some((name) => conditions.injectEnvs?.includes(name))) {
|
|
1196
|
+
return false;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
if (conditions.workingDirs?.length) {
|
|
1200
|
+
const cwd = action.workingDir ? path.resolve(action.workingDir) : "";
|
|
1201
|
+
if (!conditions.workingDirs.includes(cwd)) {
|
|
1202
|
+
return false;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (conditions.sshTargets?.length) {
|
|
1206
|
+
const target = action.ssh?.target ? normalizeSshTarget(action.ssh.target) : "";
|
|
1207
|
+
if (!conditions.sshTargets.includes(target)) {
|
|
1208
|
+
return false;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
if (conditions.sshPorts?.length) {
|
|
1212
|
+
const port = action.ssh?.port ? normalizeSshPort(action.ssh.port) : undefined;
|
|
1213
|
+
if (!port || !conditions.sshPorts.includes(port)) {
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
return true;
|
|
1218
|
+
}
|
|
1219
|
+
function requestCreationMessage(record, grant, policyRule, supersededCount) {
|
|
1220
|
+
if (policyRule?.decision === "deny") {
|
|
1221
|
+
return `Denied execution request ${record.id} by approval policy ${policyRule.name}.`;
|
|
1222
|
+
}
|
|
1223
|
+
if (policyRule?.decision === "allow") {
|
|
1224
|
+
return `Created execution request ${record.id} using approval policy ${policyRule.name}.`;
|
|
1225
|
+
}
|
|
1226
|
+
if (grant) {
|
|
1227
|
+
return `Created execution request ${record.id} using approval grant ${grant.id}.`;
|
|
1228
|
+
}
|
|
1229
|
+
return `Created execution request ${record.id}${supersededCount ? ` and superseded ${supersededCount} older duplicate(s)` : ""}.`;
|
|
1230
|
+
}
|
|
1231
|
+
function normalizeApprovalPolicyRule(rule, nowIso = new Date().toISOString()) {
|
|
1232
|
+
const decision = isApprovalPolicyDecision(rule.decision) ? rule.decision : "ask";
|
|
1233
|
+
return {
|
|
1234
|
+
id: typeof rule.id === "string" && rule.id.trim() ? rule.id.trim() : shortId("policy"),
|
|
1235
|
+
name: typeof rule.name === "string" && rule.name.trim() ? rule.name.trim().slice(0, 120) : defaultPolicyRuleName(decision),
|
|
1236
|
+
enabled: rule.enabled !== false,
|
|
1237
|
+
priority: normalizePolicyPriority(rule.priority),
|
|
1238
|
+
decision,
|
|
1239
|
+
conditions: normalizeApprovalPolicyConditions(rule.conditions),
|
|
1240
|
+
expiresAt: normalizePolicyExpiresAt(rule.expiresAt),
|
|
1241
|
+
createdAt: typeof rule.createdAt === "string" && rule.createdAt ? rule.createdAt : nowIso,
|
|
1242
|
+
updatedAt: typeof rule.updatedAt === "string" && rule.updatedAt ? rule.updatedAt : nowIso
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
function normalizeApprovalPolicyConditions(input) {
|
|
1246
|
+
return {
|
|
1247
|
+
handles: optionalStrings(input?.handles),
|
|
1248
|
+
secretTypes: optionalSecretTypes(input?.secretTypes),
|
|
1249
|
+
providers: optionalStrings(input?.providers).map((provider) => provider.toLowerCase()),
|
|
1250
|
+
minSeverity: isSecretSeverity(input?.minSeverity) ? input.minSeverity : undefined,
|
|
1251
|
+
agents: optionalStrings(input?.agents).map((agent) => agent.toLowerCase()),
|
|
1252
|
+
actionKinds: optionalActionKinds(input?.actionKinds),
|
|
1253
|
+
commands: optionalStrings(input?.commands).map((command) => safeNormalizeCommandGrant(command)).filter(Boolean),
|
|
1254
|
+
injectEnvs: optionalStrings(input?.injectEnvs),
|
|
1255
|
+
workingDirs: optionalStrings(input?.workingDirs).map((dir) => path.resolve(dir)),
|
|
1256
|
+
sshTargets: optionalStrings(input?.sshTargets).map((target) => normalizeSshTarget(target)),
|
|
1257
|
+
sshPorts: optionalPorts(input?.sshPorts)
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
function safeNormalizeCommandGrant(command) {
|
|
1261
|
+
try {
|
|
1262
|
+
return normalizeCommandGrant(command);
|
|
1263
|
+
}
|
|
1264
|
+
catch {
|
|
1265
|
+
return undefined;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
function isValidApprovalPolicyRule(rule) {
|
|
1269
|
+
return (Boolean(rule) &&
|
|
1270
|
+
typeof rule.id === "string" &&
|
|
1271
|
+
typeof rule.name === "string" &&
|
|
1272
|
+
typeof rule.enabled === "boolean" &&
|
|
1273
|
+
typeof rule.priority === "number" &&
|
|
1274
|
+
isApprovalPolicyDecision(rule.decision) &&
|
|
1275
|
+
Boolean(rule.conditions) &&
|
|
1276
|
+
typeof rule.createdAt === "string" &&
|
|
1277
|
+
typeof rule.updatedAt === "string");
|
|
1278
|
+
}
|
|
1279
|
+
function isApprovalPolicyDecision(value) {
|
|
1280
|
+
return value === "ask" || value === "allow" || value === "deny";
|
|
1281
|
+
}
|
|
1282
|
+
function sortApprovalPolicyRules(rules) {
|
|
1283
|
+
return [...rules].sort((a, b) => {
|
|
1284
|
+
const priority = a.priority - b.priority;
|
|
1285
|
+
return priority || b.updatedAt.localeCompare(a.updatedAt);
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
function nextPolicyPriority(store) {
|
|
1289
|
+
const priorities = (store.approvalPolicyRules || []).map((rule) => rule.priority).filter(Number.isFinite);
|
|
1290
|
+
return priorities.length ? Math.max(...priorities) + 10 : 100;
|
|
1291
|
+
}
|
|
1292
|
+
function defaultPolicyRuleName(decision) {
|
|
1293
|
+
switch (decision) {
|
|
1294
|
+
case "allow":
|
|
1295
|
+
return "Allow matching agent access";
|
|
1296
|
+
case "deny":
|
|
1297
|
+
return "Deny matching agent access";
|
|
1298
|
+
case "ask":
|
|
1299
|
+
return "Require approval for matching access";
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
function policyExpiresAt(input, nowIso) {
|
|
1303
|
+
const explicit = normalizePolicyExpiresAt(input.expiresAt);
|
|
1304
|
+
if (explicit) {
|
|
1305
|
+
return explicit;
|
|
1306
|
+
}
|
|
1307
|
+
if (input.durationMs === undefined) {
|
|
1308
|
+
return undefined;
|
|
1309
|
+
}
|
|
1310
|
+
return new Date(Date.parse(nowIso) + clampApprovalDuration(input.durationMs)).toISOString();
|
|
1311
|
+
}
|
|
1312
|
+
function normalizePolicyExpiresAt(value) {
|
|
1313
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1314
|
+
return undefined;
|
|
1315
|
+
}
|
|
1316
|
+
const parsed = Date.parse(value);
|
|
1317
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
|
|
1318
|
+
}
|
|
1319
|
+
function normalizePolicyPriority(value) {
|
|
1320
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
1321
|
+
return 100;
|
|
1322
|
+
}
|
|
1323
|
+
return Math.max(0, Math.min(10_000, Math.floor(value)));
|
|
1324
|
+
}
|
|
1325
|
+
function optionalStrings(values) {
|
|
1326
|
+
if (!Array.isArray(values)) {
|
|
1327
|
+
return [];
|
|
1328
|
+
}
|
|
1329
|
+
return uniqueStrings(values.filter((value) => typeof value === "string"));
|
|
1330
|
+
}
|
|
1331
|
+
function optionalSecretTypes(values) {
|
|
1332
|
+
if (!Array.isArray(values)) {
|
|
1333
|
+
return [];
|
|
1334
|
+
}
|
|
1335
|
+
return uniqueStrings(values.filter((value) => isSecretType(value)));
|
|
1336
|
+
}
|
|
1337
|
+
function optionalActionKinds(values) {
|
|
1338
|
+
if (!Array.isArray(values)) {
|
|
1339
|
+
return [];
|
|
1340
|
+
}
|
|
1341
|
+
return uniqueStrings(values.filter((value) => {
|
|
1342
|
+
return value === "env_command" || value === "ssh_session";
|
|
1343
|
+
}));
|
|
1344
|
+
}
|
|
1345
|
+
function optionalPorts(values) {
|
|
1346
|
+
if (!Array.isArray(values)) {
|
|
1347
|
+
return [];
|
|
1348
|
+
}
|
|
1349
|
+
const out = [];
|
|
1350
|
+
for (const value of values) {
|
|
1351
|
+
const port = Number(value);
|
|
1352
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535 || out.includes(port)) {
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
out.push(port);
|
|
1356
|
+
}
|
|
1357
|
+
return out;
|
|
1358
|
+
}
|
|
1359
|
+
function isSecretType(value) {
|
|
1360
|
+
return (value === "api-token" ||
|
|
1361
|
+
value === "ssh-key" ||
|
|
1362
|
+
value === "private-key" ||
|
|
1363
|
+
value === "password" ||
|
|
1364
|
+
value === "credential" ||
|
|
1365
|
+
value === "access-key" ||
|
|
1366
|
+
value === "unknown");
|
|
1367
|
+
}
|
|
1368
|
+
function isSecretSeverity(value) {
|
|
1369
|
+
return value === "low" || value === "medium" || value === "high" || value === "critical";
|
|
1370
|
+
}
|
|
1371
|
+
function severityRank(value) {
|
|
1372
|
+
switch (value) {
|
|
1373
|
+
case "low":
|
|
1374
|
+
return 0;
|
|
1375
|
+
case "medium":
|
|
1376
|
+
return 1;
|
|
1377
|
+
case "high":
|
|
1378
|
+
return 2;
|
|
1379
|
+
case "critical":
|
|
1380
|
+
return 3;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
function currentLoginSessionId() {
|
|
1384
|
+
const override = process.env.SGW_LOGIN_SESSION_ID?.trim();
|
|
1385
|
+
if (override) {
|
|
1386
|
+
return override.slice(0, 160);
|
|
1387
|
+
}
|
|
1388
|
+
const user = os.userInfo();
|
|
1389
|
+
const parts = [
|
|
1390
|
+
process.platform,
|
|
1391
|
+
String(user.uid),
|
|
1392
|
+
user.username,
|
|
1393
|
+
process.env.TMPDIR || "",
|
|
1394
|
+
process.env.XDG_RUNTIME_DIR || "",
|
|
1395
|
+
process.env.SSH_AUTH_SOCK || ""
|
|
1396
|
+
];
|
|
1397
|
+
return createHash("sha256").update(parts.join("\0")).digest("base64url").slice(0, 32);
|
|
1398
|
+
}
|
|
1399
|
+
function normalizePolicy(input, existing) {
|
|
1400
|
+
const allowedCommands = input?.allowedCommands ?? existing?.allowedCommands ?? [];
|
|
1401
|
+
return {
|
|
1402
|
+
injectEnv: input?.injectEnv ?? existing?.injectEnv,
|
|
1403
|
+
allowedCommands: uniqueStrings(allowedCommands),
|
|
1404
|
+
maxOutputBytes: input?.maxOutputBytes ?? existing?.maxOutputBytes ?? 16_384
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
function normalizeAction(action) {
|
|
1408
|
+
const kind = action.kind === "ssh_session" ? "ssh_session" : "env_command";
|
|
1409
|
+
if (kind === "ssh_session") {
|
|
1410
|
+
return {
|
|
1411
|
+
kind,
|
|
1412
|
+
command: SGW_SSH_SESSION_COMMAND,
|
|
1413
|
+
args: Array.isArray(action.args) ? action.args : [],
|
|
1414
|
+
injectEnv: action.injectEnv || "SGW_SSH_CREDENTIAL",
|
|
1415
|
+
env: [],
|
|
1416
|
+
workingDir: action.workingDir,
|
|
1417
|
+
timeoutMs: clampTimeout(action.timeoutMs),
|
|
1418
|
+
ssh: {
|
|
1419
|
+
target: normalizeSshTarget(action.ssh?.target || ""),
|
|
1420
|
+
port: normalizeSshPort(action.ssh?.port)
|
|
1421
|
+
}
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
return {
|
|
1425
|
+
kind,
|
|
1426
|
+
command: action.command,
|
|
1427
|
+
args: Array.isArray(action.args) ? action.args : [],
|
|
1428
|
+
injectEnv: action.injectEnv,
|
|
1429
|
+
env: normalizeEnvBindings(action.env),
|
|
1430
|
+
workingDir: action.workingDir,
|
|
1431
|
+
timeoutMs: clampTimeout(action.timeoutMs)
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
function normalizeEnvBindings(bindings) {
|
|
1435
|
+
if (!Array.isArray(bindings) || bindings.length === 0) {
|
|
1436
|
+
return [];
|
|
1437
|
+
}
|
|
1438
|
+
const seenEnv = new Set();
|
|
1439
|
+
const normalized = [];
|
|
1440
|
+
for (const binding of bindings) {
|
|
1441
|
+
const handle = binding?.handle?.trim();
|
|
1442
|
+
const injectEnv = binding?.injectEnv?.trim();
|
|
1443
|
+
if (!handle) {
|
|
1444
|
+
throw new Error("Additional env bindings require a handle.");
|
|
1445
|
+
}
|
|
1446
|
+
if (!injectEnv || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(injectEnv)) {
|
|
1447
|
+
throw new Error(`Invalid environment variable name: ${injectEnv || "(empty)"}`);
|
|
1448
|
+
}
|
|
1449
|
+
if (handle.includes("\0") || injectEnv.includes("\0")) {
|
|
1450
|
+
throw new Error("Additional env bindings cannot contain null bytes.");
|
|
1451
|
+
}
|
|
1452
|
+
if (seenEnv.has(injectEnv)) {
|
|
1453
|
+
throw new Error(`Environment variable ${injectEnv} is bound more than once.`);
|
|
1454
|
+
}
|
|
1455
|
+
seenEnv.add(injectEnv);
|
|
1456
|
+
normalized.push({ handle, injectEnv });
|
|
1457
|
+
}
|
|
1458
|
+
normalized.sort((a, b) => {
|
|
1459
|
+
const envOrder = a.injectEnv.localeCompare(b.injectEnv);
|
|
1460
|
+
return envOrder || a.handle.localeCompare(b.handle);
|
|
1461
|
+
});
|
|
1462
|
+
return normalized;
|
|
1463
|
+
}
|
|
1464
|
+
function assertBoundHandlesAllowed(store, primaryHandle, action) {
|
|
1465
|
+
const seenEnv = new Set([action.injectEnv]);
|
|
1466
|
+
for (const binding of action.env || []) {
|
|
1467
|
+
if (binding.handle === primaryHandle) {
|
|
1468
|
+
throw new Error(`Additional env binding ${binding.injectEnv} repeats the primary handle ${primaryHandle}.`);
|
|
1469
|
+
}
|
|
1470
|
+
if (seenEnv.has(binding.injectEnv)) {
|
|
1471
|
+
throw new Error(`Environment variable ${binding.injectEnv} is bound more than once.`);
|
|
1472
|
+
}
|
|
1473
|
+
const secret = store.secrets.find((item) => item.handle === binding.handle);
|
|
1474
|
+
if (!secret) {
|
|
1475
|
+
throw new Error(`Unknown secret handle: ${binding.handle}`);
|
|
1476
|
+
}
|
|
1477
|
+
assertActionAllowed(secret, {
|
|
1478
|
+
...action,
|
|
1479
|
+
injectEnv: binding.injectEnv,
|
|
1480
|
+
env: []
|
|
1481
|
+
});
|
|
1482
|
+
seenEnv.add(binding.injectEnv);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
export function assertActionAllowed(secret, action) {
|
|
1486
|
+
if (action.kind !== "env_command" && action.kind !== "ssh_session") {
|
|
1487
|
+
throw new Error("Unsupported request action kind.");
|
|
1488
|
+
}
|
|
1489
|
+
if (!action.command || action.command.includes("\0")) {
|
|
1490
|
+
throw new Error("Command is required.");
|
|
1491
|
+
}
|
|
1492
|
+
for (const arg of action.args) {
|
|
1493
|
+
if (arg.includes("\0")) {
|
|
1494
|
+
throw new Error("Command arguments cannot contain null bytes.");
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
if (!action.injectEnv) {
|
|
1498
|
+
throw new Error("injectEnv is required so the secret has a narrow local binding.");
|
|
1499
|
+
}
|
|
1500
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(action.injectEnv)) {
|
|
1501
|
+
throw new Error(`Invalid environment variable name: ${action.injectEnv}`);
|
|
1502
|
+
}
|
|
1503
|
+
if (secret.policy.injectEnv && action.injectEnv !== secret.policy.injectEnv) {
|
|
1504
|
+
throw new Error(`Handle ${secret.handle} can only be injected as ${secret.policy.injectEnv}.`);
|
|
1505
|
+
}
|
|
1506
|
+
const allowed = secret.policy.allowedCommands.map((cmd) => normalizeCommandGrant(cmd));
|
|
1507
|
+
if (action.kind === "ssh_session") {
|
|
1508
|
+
normalizeSshTarget(action.ssh?.target || "");
|
|
1509
|
+
normalizeSshPort(action.ssh?.port);
|
|
1510
|
+
if (allowed.length === 0 || !allowed.some(commandAllowsSshSession)) {
|
|
1511
|
+
throw new Error(`Handle ${secret.handle} is not allowed for s-gw-owned SSH sessions. Add ${SGW_SSH_SESSION_COMMAND} to the handle policy.`);
|
|
1512
|
+
}
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
const requestedCommand = normalizeCommandGrant(action.command);
|
|
1516
|
+
if (allowed.length === 0 || !allowed.includes(requestedCommand)) {
|
|
1517
|
+
throw new Error(`Command '${action.command}' is not allowed for handle ${secret.handle}.`);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
function commandAllowsSshSession(command) {
|
|
1521
|
+
return command === SGW_SSH_SESSION_COMMAND || path.basename(command) === "ssh";
|
|
1522
|
+
}
|
|
1523
|
+
function normalizeCommandGrant(command) {
|
|
1524
|
+
const trimmed = command.trim();
|
|
1525
|
+
if (!trimmed) {
|
|
1526
|
+
throw new Error("Command grant cannot be empty.");
|
|
1527
|
+
}
|
|
1528
|
+
if (trimmed.includes("\0")) {
|
|
1529
|
+
throw new Error("Command grant cannot contain null bytes.");
|
|
1530
|
+
}
|
|
1531
|
+
if (path.isAbsolute(trimmed)) {
|
|
1532
|
+
return path.normalize(trimmed);
|
|
1533
|
+
}
|
|
1534
|
+
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
|
1535
|
+
throw new Error(`Relative command paths are not allowed: ${trimmed}`);
|
|
1536
|
+
}
|
|
1537
|
+
return trimmed;
|
|
1538
|
+
}
|
|
1539
|
+
function summarizeSecret(secret) {
|
|
1540
|
+
return {
|
|
1541
|
+
handle: secret.handle,
|
|
1542
|
+
name: secret.name,
|
|
1543
|
+
type: secret.type,
|
|
1544
|
+
backend: secret.backend || "local",
|
|
1545
|
+
provider: secret.provider,
|
|
1546
|
+
ruleId: secret.ruleId,
|
|
1547
|
+
severity: secret.severity,
|
|
1548
|
+
confidence: secret.confidence,
|
|
1549
|
+
createdAt: secret.createdAt,
|
|
1550
|
+
updatedAt: secret.updatedAt,
|
|
1551
|
+
source: secret.source,
|
|
1552
|
+
fingerprint: secret.fingerprint,
|
|
1553
|
+
policy: secret.policy
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
function makeHandle(type) {
|
|
1557
|
+
return `s-gw:${type}:${shortId()}`;
|
|
1558
|
+
}
|
|
1559
|
+
function uniqueStrings(values) {
|
|
1560
|
+
const seen = new Set();
|
|
1561
|
+
const out = [];
|
|
1562
|
+
for (const value of values) {
|
|
1563
|
+
const trimmed = value.trim();
|
|
1564
|
+
if (!trimmed || seen.has(trimmed)) {
|
|
1565
|
+
continue;
|
|
1566
|
+
}
|
|
1567
|
+
seen.add(trimmed);
|
|
1568
|
+
out.push(trimmed);
|
|
1569
|
+
}
|
|
1570
|
+
return out;
|
|
1571
|
+
}
|
|
1572
|
+
function clampTimeout(timeoutMs) {
|
|
1573
|
+
if (!Number.isFinite(timeoutMs)) {
|
|
1574
|
+
return 30_000;
|
|
1575
|
+
}
|
|
1576
|
+
if (timeoutMs === 0) {
|
|
1577
|
+
return 0;
|
|
1578
|
+
}
|
|
1579
|
+
if (timeoutMs < 0) {
|
|
1580
|
+
return 30_000;
|
|
1581
|
+
}
|
|
1582
|
+
return Math.min(Math.floor(timeoutMs), 24 * 60 * 60 * 1000);
|
|
1583
|
+
}
|
|
1584
|
+
function audit(type, message, handle, requestId) {
|
|
1585
|
+
return {
|
|
1586
|
+
id: shortId("audit"),
|
|
1587
|
+
ts: new Date().toISOString(),
|
|
1588
|
+
type,
|
|
1589
|
+
handle,
|
|
1590
|
+
requestId,
|
|
1591
|
+
message
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
async function removeStaleLock(lockPath) {
|
|
1595
|
+
try {
|
|
1596
|
+
const info = await stat(lockPath);
|
|
1597
|
+
if (Date.now() - info.mtimeMs > staleLockMs) {
|
|
1598
|
+
await unlink(lockPath).catch(() => undefined);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
catch {
|
|
1602
|
+
// Another process may have released the lock between our open and stat.
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
async function sleep(ms) {
|
|
1606
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
1607
|
+
}
|
|
1608
|
+
function isNodeError(error) {
|
|
1609
|
+
return error instanceof Error && "code" in error;
|
|
1610
|
+
}
|
|
1611
|
+
//# sourceMappingURL=store.js.map
|