@stitchaxis/latchkey 0.0.0-stage → 0.1.1
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/README.md +48 -2
- package/bin/latchkey.js +2 -0
- package/dist/audit.d.ts +11 -0
- package/dist/audit.js +7 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +260 -0
- package/dist/crypto.d.ts +18 -0
- package/dist/crypto.js +43 -0
- package/dist/crypto.test.d.ts +1 -0
- package/dist/crypto.test.js +21 -0
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +45 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/keychain.d.ts +8 -0
- package/dist/keychain.js +73 -0
- package/dist/licensing/access.d.ts +8 -0
- package/dist/licensing/access.js +35 -0
- package/dist/licensing/cache.d.ts +11 -0
- package/dist/licensing/cache.js +42 -0
- package/dist/licensing/enforce.d.ts +3 -0
- package/dist/licensing/enforce.js +20 -0
- package/dist/licensing/enforce.test.d.ts +1 -0
- package/dist/licensing/enforce.test.js +57 -0
- package/dist/licensing/registry.d.ts +5 -0
- package/dist/licensing/registry.js +59 -0
- package/dist/licensing/trial.d.ts +6 -0
- package/dist/licensing/trial.js +23 -0
- package/dist/lock.d.ts +6 -0
- package/dist/lock.js +55 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +82 -0
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +15 -0
- package/dist/redact.d.ts +5 -0
- package/dist/redact.js +32 -0
- package/dist/redact.test.d.ts +1 -0
- package/dist/redact.test.js +12 -0
- package/dist/sandbox.d.ts +4 -0
- package/dist/sandbox.js +10 -0
- package/dist/scan.d.ts +6 -0
- package/dist/scan.js +50 -0
- package/dist/scope.d.ts +9 -0
- package/dist/scope.js +36 -0
- package/dist/scope.test.d.ts +1 -0
- package/dist/scope.test.js +15 -0
- package/dist/vault.d.ts +35 -0
- package/dist/vault.js +171 -0
- package/dist/vault.test.d.ts +1 -0
- package/dist/vault.test.js +32 -0
- package/package.json +39 -4
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getEffectiveAccess, requireHardenedApproval } from "./access.js";
|
|
2
|
+
import { countVaults, registerProject, registerThisDevice, } from "./registry.js";
|
|
3
|
+
export async function enforceBeforeSecretAccess(projectPath) {
|
|
4
|
+
const { payload } = await getEffectiveAccess();
|
|
5
|
+
requireHardenedApproval(payload);
|
|
6
|
+
if (projectPath) {
|
|
7
|
+
await registerProject(projectPath, payload.projects);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export async function enforceBeforeVaultWrite() {
|
|
11
|
+
const { payload } = await getEffectiveAccess();
|
|
12
|
+
const vaults = countVaults();
|
|
13
|
+
if (vaults >= payload.vaults) {
|
|
14
|
+
throw new Error(`Vault limit reached (${payload.vaults}). Upgrade at https://modexventures.com/latchkey/pricing`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function enforceOnLogin() {
|
|
18
|
+
const { payload } = await getEffectiveAccess();
|
|
19
|
+
await registerThisDevice(payload.devices);
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { issueLicenseToken, verifyAndDecodeLicense, generateTestKeyPair, } from "@stitchaxis/license";
|
|
6
|
+
describe("license enforcement", () => {
|
|
7
|
+
let prevHome;
|
|
8
|
+
let prevPub;
|
|
9
|
+
let tempHome;
|
|
10
|
+
let privateKeyPem;
|
|
11
|
+
let publicKeyPem;
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
prevHome = process.env.LATCHKEY_HOME;
|
|
14
|
+
prevPub = process.env.LICENSE_VERIFY_PUBLIC_KEY;
|
|
15
|
+
tempHome = await mkdtemp(join(tmpdir(), "latchkey-lic-"));
|
|
16
|
+
process.env.LATCHKEY_HOME = tempHome;
|
|
17
|
+
const pair = generateTestKeyPair();
|
|
18
|
+
privateKeyPem = pair.privateKeyPem;
|
|
19
|
+
publicKeyPem = pair.publicKeyPem;
|
|
20
|
+
process.env.LICENSE_VERIFY_PUBLIC_KEY = publicKeyPem;
|
|
21
|
+
});
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
process.env.LATCHKEY_HOME = prevHome;
|
|
24
|
+
if (prevPub === undefined) {
|
|
25
|
+
delete process.env.LICENSE_VERIFY_PUBLIC_KEY;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
process.env.LICENSE_VERIFY_PUBLIC_KEY = prevPub;
|
|
29
|
+
}
|
|
30
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
it("verifies login token and caches payload", async () => {
|
|
33
|
+
const token = issueLicenseToken({
|
|
34
|
+
email: "a@example.com",
|
|
35
|
+
planId: "pro",
|
|
36
|
+
privateKeyPem,
|
|
37
|
+
});
|
|
38
|
+
const { loginWithToken } = await import("./cache.js");
|
|
39
|
+
const payload = await loginWithToken(token);
|
|
40
|
+
expect(payload.tier).toBe("pro");
|
|
41
|
+
expect(verifyAndDecodeLicense(token, publicKeyPem).devices).toBe(3);
|
|
42
|
+
});
|
|
43
|
+
it("enforces scoped project limit", async () => {
|
|
44
|
+
const token = issueLicenseToken({
|
|
45
|
+
email: "a@example.com",
|
|
46
|
+
planId: "solo",
|
|
47
|
+
privateKeyPem,
|
|
48
|
+
});
|
|
49
|
+
const { loginWithToken } = await import("./cache.js");
|
|
50
|
+
await loginWithToken(token);
|
|
51
|
+
const { registerProject } = await import("./registry.js");
|
|
52
|
+
await registerProject("/p1", 3);
|
|
53
|
+
await registerProject("/p2", 3);
|
|
54
|
+
await registerProject("/p3", 3);
|
|
55
|
+
await expect(registerProject("/p4", 3)).rejects.toThrow(/project limit/i);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function getMachineId(): Promise<string>;
|
|
2
|
+
export declare function registerThisDevice(maxDevices: number): Promise<void>;
|
|
3
|
+
export declare function countVaults(): number;
|
|
4
|
+
export declare function registerProject(projectPath: string, maxProjects: number | null): Promise<void>;
|
|
5
|
+
export declare function countProjects(): Promise<number>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { DEVICES_FILE, MACHINE_ID_FILE, PROJECTS_FILE, LATCHKEY_DIR, VAULT_FILE, } from "../paths.js";
|
|
5
|
+
export async function getMachineId() {
|
|
6
|
+
await mkdir(LATCHKEY_DIR, { recursive: true });
|
|
7
|
+
if (existsSync(MACHINE_ID_FILE)) {
|
|
8
|
+
return (await readFile(MACHINE_ID_FILE, "utf8")).trim();
|
|
9
|
+
}
|
|
10
|
+
const id = randomUUID();
|
|
11
|
+
await writeFile(MACHINE_ID_FILE, id, "utf8");
|
|
12
|
+
return id;
|
|
13
|
+
}
|
|
14
|
+
async function readDevices() {
|
|
15
|
+
if (!existsSync(DEVICES_FILE))
|
|
16
|
+
return { registered: [] };
|
|
17
|
+
return JSON.parse(await readFile(DEVICES_FILE, "utf8"));
|
|
18
|
+
}
|
|
19
|
+
async function writeDevices(reg) {
|
|
20
|
+
await mkdir(LATCHKEY_DIR, { recursive: true });
|
|
21
|
+
await writeFile(DEVICES_FILE, JSON.stringify(reg, null, 2), "utf8");
|
|
22
|
+
}
|
|
23
|
+
export async function registerThisDevice(maxDevices) {
|
|
24
|
+
const machineId = await getMachineId();
|
|
25
|
+
const reg = await readDevices();
|
|
26
|
+
if (reg.registered.includes(machineId))
|
|
27
|
+
return;
|
|
28
|
+
if (reg.registered.length >= maxDevices) {
|
|
29
|
+
throw new Error(`Device limit reached (${maxDevices}). Upgrade your plan at https://modexventures.com/latchkey/pricing or retire another device from your account.`);
|
|
30
|
+
}
|
|
31
|
+
reg.registered.push(machineId);
|
|
32
|
+
await writeDevices(reg);
|
|
33
|
+
}
|
|
34
|
+
export function countVaults() {
|
|
35
|
+
return existsSync(VAULT_FILE) ? 1 : 0;
|
|
36
|
+
}
|
|
37
|
+
export async function registerProject(projectPath, maxProjects) {
|
|
38
|
+
if (maxProjects === null)
|
|
39
|
+
return;
|
|
40
|
+
await mkdir(LATCHKEY_DIR, { recursive: true });
|
|
41
|
+
let reg = { paths: [] };
|
|
42
|
+
if (existsSync(PROJECTS_FILE)) {
|
|
43
|
+
reg = JSON.parse(await readFile(PROJECTS_FILE, "utf8"));
|
|
44
|
+
}
|
|
45
|
+
const normalized = projectPath;
|
|
46
|
+
if (reg.paths.includes(normalized))
|
|
47
|
+
return;
|
|
48
|
+
if (reg.paths.length >= maxProjects) {
|
|
49
|
+
throw new Error(`Scoped project limit reached (${maxProjects}). Upgrade at https://modexventures.com/latchkey/pricing`);
|
|
50
|
+
}
|
|
51
|
+
reg.paths.push(normalized);
|
|
52
|
+
await writeFile(PROJECTS_FILE, JSON.stringify(reg, null, 2), "utf8");
|
|
53
|
+
}
|
|
54
|
+
export async function countProjects() {
|
|
55
|
+
if (!existsSync(PROJECTS_FILE))
|
|
56
|
+
return 0;
|
|
57
|
+
const reg = JSON.parse(await readFile(PROJECTS_FILE, "utf8"));
|
|
58
|
+
return reg.paths.length;
|
|
59
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { TRIAL_FILE, LATCHKEY_DIR } from "../paths.js";
|
|
4
|
+
const TRIAL_DAYS = 14;
|
|
5
|
+
export async function ensureTrialStarted() {
|
|
6
|
+
await mkdir(LATCHKEY_DIR, { recursive: true });
|
|
7
|
+
if (existsSync(TRIAL_FILE)) {
|
|
8
|
+
const raw = await readFile(TRIAL_FILE, "utf8");
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
}
|
|
11
|
+
const state = { startedAt: new Date().toISOString() };
|
|
12
|
+
await writeFile(TRIAL_FILE, JSON.stringify(state, null, 2), "utf8");
|
|
13
|
+
return state;
|
|
14
|
+
}
|
|
15
|
+
export async function trialDaysRemaining() {
|
|
16
|
+
const state = await ensureTrialStarted();
|
|
17
|
+
const end = new Date(state.startedAt).getTime() + TRIAL_DAYS * 24 * 60 * 60 * 1000;
|
|
18
|
+
const left = Math.ceil((end - Date.now()) / (24 * 60 * 60 * 1000));
|
|
19
|
+
return Math.max(0, left);
|
|
20
|
+
}
|
|
21
|
+
export async function isTrialActive() {
|
|
22
|
+
return (await trialDaysRemaining()) > 0;
|
|
23
|
+
}
|
package/dist/lock.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function configureIdleLock(ms: number): void;
|
|
2
|
+
export declare function setCachedMasterKey(key: Uint8Array | null): void;
|
|
3
|
+
export declare function getCachedMasterKey(): Uint8Array | null;
|
|
4
|
+
export declare function touchActivity(): void;
|
|
5
|
+
export declare function isLocked(): boolean;
|
|
6
|
+
export declare function lockVault(): void;
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const DEFAULT_IDLE_MS = 15 * 60 * 1000;
|
|
2
|
+
let cachedMasterKey = null;
|
|
3
|
+
let lastActivity = 0;
|
|
4
|
+
let idleMs = DEFAULT_IDLE_MS;
|
|
5
|
+
let idleTimer = null;
|
|
6
|
+
export function configureIdleLock(ms) {
|
|
7
|
+
idleMs = ms;
|
|
8
|
+
}
|
|
9
|
+
export function setCachedMasterKey(key) {
|
|
10
|
+
cachedMasterKey = key;
|
|
11
|
+
if (key) {
|
|
12
|
+
touchActivity();
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
clearIdleTimer();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function getCachedMasterKey() {
|
|
19
|
+
if (cachedMasterKey && isLocked()) {
|
|
20
|
+
cachedMasterKey = null;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return cachedMasterKey;
|
|
24
|
+
}
|
|
25
|
+
export function touchActivity() {
|
|
26
|
+
lastActivity = Date.now();
|
|
27
|
+
scheduleIdleLock();
|
|
28
|
+
}
|
|
29
|
+
export function isLocked() {
|
|
30
|
+
if (!cachedMasterKey)
|
|
31
|
+
return true;
|
|
32
|
+
if (Date.now() - lastActivity > idleMs) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
export function lockVault() {
|
|
38
|
+
cachedMasterKey = null;
|
|
39
|
+
lastActivity = 0;
|
|
40
|
+
clearIdleTimer();
|
|
41
|
+
}
|
|
42
|
+
function clearIdleTimer() {
|
|
43
|
+
if (idleTimer) {
|
|
44
|
+
clearTimeout(idleTimer);
|
|
45
|
+
idleTimer = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function scheduleIdleLock() {
|
|
49
|
+
clearIdleTimer();
|
|
50
|
+
idleTimer = setTimeout(() => {
|
|
51
|
+
if (cachedMasterKey && Date.now() - lastActivity > idleMs) {
|
|
52
|
+
cachedMasterKey = null;
|
|
53
|
+
}
|
|
54
|
+
}, idleMs + 100);
|
|
55
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCP server mode (stretch): agents may request scoped keys — requires explicit
|
|
4
|
+
* LATCHKEY_MCP_APPROVE=1 in the environment for each grant (human-in-the-loop).
|
|
5
|
+
*/
|
|
6
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import { getSecret } from "./vault.js";
|
|
10
|
+
import { redactSecrets } from "./redact.js";
|
|
11
|
+
import { auditLog } from "./audit.js";
|
|
12
|
+
const server = new Server({ name: "latchkey", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
13
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
14
|
+
tools: [
|
|
15
|
+
{
|
|
16
|
+
name: "latchkey_get_secret",
|
|
17
|
+
description: "Request a vault secret by env var name. Requires LATCHKEY_MCP_APPROVE=1 and respects .latchkey.toml scope.",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
name: { type: "string", description: "Secret name e.g. OPENAI_API_KEY" },
|
|
22
|
+
},
|
|
23
|
+
required: ["name"],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
}));
|
|
28
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
29
|
+
if (request.params.name !== "latchkey_get_secret") {
|
|
30
|
+
throw new Error("Unknown tool");
|
|
31
|
+
}
|
|
32
|
+
const name = request.params.arguments?.name;
|
|
33
|
+
if (!name) {
|
|
34
|
+
return { content: [{ type: "text", text: "Missing name" }], isError: true };
|
|
35
|
+
}
|
|
36
|
+
if (process.env.LATCHKEY_MCP_APPROVE !== "1") {
|
|
37
|
+
await auditLog({
|
|
38
|
+
action: "mcp_deny",
|
|
39
|
+
name,
|
|
40
|
+
detail: "LATCHKEY_MCP_APPROVE not set",
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
content: [
|
|
44
|
+
{
|
|
45
|
+
type: "text",
|
|
46
|
+
text: "Denied: set LATCHKEY_MCP_APPROVE=1 in your shell after reviewing the request.",
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
isError: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const value = await getSecret(name, { enforceScope: true });
|
|
54
|
+
await auditLog({
|
|
55
|
+
action: "mcp_approve",
|
|
56
|
+
name,
|
|
57
|
+
detail: "latchkey_get_secret",
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
content: [{ type: "text", text: value }],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
await auditLog({
|
|
65
|
+
action: "mcp_error",
|
|
66
|
+
name,
|
|
67
|
+
detail: "get_secret_failed",
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: "text", text: redactSecrets(String(e)) }],
|
|
71
|
+
isError: true,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
async function main() {
|
|
76
|
+
const transport = new StdioServerTransport();
|
|
77
|
+
await server.connect(transport);
|
|
78
|
+
}
|
|
79
|
+
main().catch((e) => {
|
|
80
|
+
process.stderr.write(redactSecrets(String(e)) + "\n");
|
|
81
|
+
process.exit(1);
|
|
82
|
+
});
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const LATCHKEY_DIR: string;
|
|
2
|
+
export declare const VAULT_FILE: string;
|
|
3
|
+
export declare const CONFIG_FILE: string;
|
|
4
|
+
export declare const AUDIT_FILE: string;
|
|
5
|
+
export declare const PASSPHRASE_WRAP_FILE: string;
|
|
6
|
+
export declare const KEYCHAIN_SERVICE = "latchkey";
|
|
7
|
+
export declare const KEYCHAIN_ACCOUNT = "master-key";
|
|
8
|
+
export declare const PROJECT_SCOPE_FILE = ".latchkey.toml";
|
|
9
|
+
export declare const LICENSE_CACHE_FILE: string;
|
|
10
|
+
export declare const TRIAL_FILE: string;
|
|
11
|
+
export declare const MACHINE_ID_FILE: string;
|
|
12
|
+
export declare const DEVICES_FILE: string;
|
|
13
|
+
export declare const PROJECTS_FILE: string;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export const LATCHKEY_DIR = process.env.LATCHKEY_HOME ?? join(homedir(), ".latchkey");
|
|
4
|
+
export const VAULT_FILE = join(LATCHKEY_DIR, "vault.enc.json");
|
|
5
|
+
export const CONFIG_FILE = join(LATCHKEY_DIR, "config.json");
|
|
6
|
+
export const AUDIT_FILE = join(LATCHKEY_DIR, "audit.jsonl");
|
|
7
|
+
export const PASSPHRASE_WRAP_FILE = join(LATCHKEY_DIR, "master.wrap.json");
|
|
8
|
+
export const KEYCHAIN_SERVICE = "latchkey";
|
|
9
|
+
export const KEYCHAIN_ACCOUNT = "master-key";
|
|
10
|
+
export const PROJECT_SCOPE_FILE = ".latchkey.toml";
|
|
11
|
+
export const LICENSE_CACHE_FILE = join(LATCHKEY_DIR, "license.cache.json");
|
|
12
|
+
export const TRIAL_FILE = join(LATCHKEY_DIR, "trial.json");
|
|
13
|
+
export const MACHINE_ID_FILE = join(LATCHKEY_DIR, "machine.id");
|
|
14
|
+
export const DEVICES_FILE = join(LATCHKEY_DIR, "devices.json");
|
|
15
|
+
export const PROJECTS_FILE = join(LATCHKEY_DIR, "projects.json");
|
package/dist/redact.d.ts
ADDED
package/dist/redact.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Patterns for common AI / cloud API key shapes (heuristic, not exhaustive). */
|
|
2
|
+
const KEY_PATTERNS = [
|
|
3
|
+
/\bsk-[a-zA-Z0-9]{20,}\b/g,
|
|
4
|
+
/\bsk-ant-[a-zA-Z0-9_-]{20,}\b/g,
|
|
5
|
+
/\bxai-[a-zA-Z0-9]{20,}\b/g,
|
|
6
|
+
/\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
7
|
+
/\bghp_[a-zA-Z0-9]{36,}\b/g,
|
|
8
|
+
/\bgho_[a-zA-Z0-9]{36,}\b/g,
|
|
9
|
+
/\bgithub_pat_[a-zA-Z0-9_]{22,}\b/g,
|
|
10
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
11
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/gi,
|
|
12
|
+
];
|
|
13
|
+
const ENV_ASSIGNMENT = /^(export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*.+$/m;
|
|
14
|
+
/**
|
|
15
|
+
* Replace likely secret material with a fixed token. Safe for logs and stderr.
|
|
16
|
+
*/
|
|
17
|
+
export function redactSecrets(input) {
|
|
18
|
+
let out = input;
|
|
19
|
+
for (const re of KEY_PATTERNS) {
|
|
20
|
+
out = out.replace(re, "[REDACTED]");
|
|
21
|
+
}
|
|
22
|
+
return out.replace(ENV_ASSIGNMENT, (line, exp, name) => {
|
|
23
|
+
const prefix = exp ? "export " : "";
|
|
24
|
+
return `${prefix}${name}=[REDACTED]`;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function redactValue(name, value) {
|
|
28
|
+
if (/key|token|secret|password|credential/i.test(name)) {
|
|
29
|
+
return "[REDACTED]";
|
|
30
|
+
}
|
|
31
|
+
return redactSecrets(value);
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { redactSecrets, redactValue } from "./redact.js";
|
|
3
|
+
describe("redact", () => {
|
|
4
|
+
it("redacts OpenAI-style keys in text", () => {
|
|
5
|
+
const input = "failed with sk-abcdefghijklmnopqrstuvwxyz1234567890";
|
|
6
|
+
expect(redactSecrets(input)).not.toContain("sk-abc");
|
|
7
|
+
expect(redactSecrets(input)).toContain("[REDACTED]");
|
|
8
|
+
});
|
|
9
|
+
it("redacts sensitive env names", () => {
|
|
10
|
+
expect(redactValue("OPENAI_API_KEY", "sk-secret")).toBe("[REDACTED]");
|
|
11
|
+
});
|
|
12
|
+
});
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** M22-cli preview — isolation not enforced yet. */
|
|
2
|
+
export const SANDBOX_ENV = "LATCHKEY_SANDBOX_REQUESTED";
|
|
3
|
+
export function sandboxPreviewNotice() {
|
|
4
|
+
console.warn("latchkey: --sandbox is a preview flag. Process isolation is NOT enforced yet.\n" +
|
|
5
|
+
" Approve gate and .latchkey.toml scopes still apply.\n" +
|
|
6
|
+
" https://modexventures.com/latchkey/agent-sandbox");
|
|
7
|
+
}
|
|
8
|
+
export function sandboxEnvFlag() {
|
|
9
|
+
return { [SANDBOX_ENV]: "1" };
|
|
10
|
+
}
|
package/dist/scan.d.ts
ADDED
package/dist/scan.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { redactSecrets } from "./redact.js";
|
|
4
|
+
const SKIP_DIRS = new Set([
|
|
5
|
+
"node_modules",
|
|
6
|
+
".git",
|
|
7
|
+
"dist",
|
|
8
|
+
".next",
|
|
9
|
+
"coverage",
|
|
10
|
+
]);
|
|
11
|
+
const KEY_HINT = /\b(sk-[a-zA-Z0-9]{20,}|sk-ant-[a-zA-Z0-9_-]{20,}|ghp_[a-zA-Z0-9]{36,}|AKIA[0-9A-Z]{16})\b/;
|
|
12
|
+
async function walk(dir, findings) {
|
|
13
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
14
|
+
for (const ent of entries) {
|
|
15
|
+
if (ent.name.startsWith(".") && ent.name !== ".env")
|
|
16
|
+
continue;
|
|
17
|
+
const full = join(dir, ent.name);
|
|
18
|
+
if (ent.isDirectory()) {
|
|
19
|
+
if (SKIP_DIRS.has(ent.name))
|
|
20
|
+
continue;
|
|
21
|
+
await walk(full, findings);
|
|
22
|
+
}
|
|
23
|
+
else if (ent.isFile()) {
|
|
24
|
+
let text;
|
|
25
|
+
try {
|
|
26
|
+
text = await readFile(full, "utf8");
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (text.length > 2_000_000)
|
|
32
|
+
continue;
|
|
33
|
+
const lines = text.split("\n");
|
|
34
|
+
lines.forEach((line, i) => {
|
|
35
|
+
if (KEY_HINT.test(line)) {
|
|
36
|
+
findings.push({
|
|
37
|
+
file: full,
|
|
38
|
+
line: i + 1,
|
|
39
|
+
preview: redactSecrets(line.trim().slice(0, 120)),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function scanRepository(root) {
|
|
47
|
+
const findings = [];
|
|
48
|
+
await walk(root, findings);
|
|
49
|
+
return findings;
|
|
50
|
+
}
|
package/dist/scope.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface ProjectScope {
|
|
2
|
+
allowed: string[];
|
|
3
|
+
projectRoot: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Load `.latchkey.toml` from cwd upward, or at explicit project root.
|
|
7
|
+
*/
|
|
8
|
+
export declare function loadProjectScope(cwd?: string): Promise<ProjectScope | null>;
|
|
9
|
+
export declare function isKeyAllowed(scope: ProjectScope | null, keyName: string): boolean;
|
package/dist/scope.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import TOML from "toml";
|
|
5
|
+
import { PROJECT_SCOPE_FILE } from "./paths.js";
|
|
6
|
+
function normalizeList(raw) {
|
|
7
|
+
if (!Array.isArray(raw))
|
|
8
|
+
return [];
|
|
9
|
+
return raw.map((x) => String(x).trim()).filter(Boolean);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Load `.latchkey.toml` from cwd upward, or at explicit project root.
|
|
13
|
+
*/
|
|
14
|
+
export async function loadProjectScope(cwd = process.cwd()) {
|
|
15
|
+
const path = join(cwd, PROJECT_SCOPE_FILE);
|
|
16
|
+
if (!existsSync(path)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const text = await readFile(path, "utf8");
|
|
20
|
+
const parsed = TOML.parse(text);
|
|
21
|
+
const scope = (parsed.scope ?? parsed);
|
|
22
|
+
const allowed = normalizeList(scope.allowed ?? scope.allow ?? scope.keys);
|
|
23
|
+
if (allowed.length === 0) {
|
|
24
|
+
return { allowed: [], projectRoot: cwd };
|
|
25
|
+
}
|
|
26
|
+
return { allowed, projectRoot: cwd };
|
|
27
|
+
}
|
|
28
|
+
export function isKeyAllowed(scope, keyName) {
|
|
29
|
+
if (!scope) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
if (scope.allowed.length === 0) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return scope.allowed.includes(keyName);
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { isKeyAllowed } from "./scope.js";
|
|
3
|
+
describe("scope", () => {
|
|
4
|
+
it("allows any key when scope is null", () => {
|
|
5
|
+
expect(isKeyAllowed(null, "OPENAI_API_KEY")).toBe(true);
|
|
6
|
+
});
|
|
7
|
+
it("denies when allowlist is empty", () => {
|
|
8
|
+
expect(isKeyAllowed({ allowed: [], projectRoot: "/proj" }, "OPENAI_API_KEY")).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
it("allows listed keys only", () => {
|
|
11
|
+
const scope = { allowed: ["OPENAI_API_KEY"], projectRoot: "/proj" };
|
|
12
|
+
expect(isKeyAllowed(scope, "OPENAI_API_KEY")).toBe(true);
|
|
13
|
+
expect(isKeyAllowed(scope, "OTHER")).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
});
|
package/dist/vault.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { isLocked } from "./lock.js";
|
|
2
|
+
export interface EncryptedSecret {
|
|
3
|
+
name: string;
|
|
4
|
+
ciphertext: string;
|
|
5
|
+
nonce: string;
|
|
6
|
+
createdAt: string;
|
|
7
|
+
updatedAt: string;
|
|
8
|
+
}
|
|
9
|
+
export interface VaultDocument {
|
|
10
|
+
version: 1;
|
|
11
|
+
secrets: EncryptedSecret[];
|
|
12
|
+
}
|
|
13
|
+
export interface LatchkeyConfig {
|
|
14
|
+
idleLockMinutes?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function ensureVaultInitialized(options?: {
|
|
17
|
+
passphrase?: string;
|
|
18
|
+
preferPassphrase?: boolean;
|
|
19
|
+
}): Promise<void>;
|
|
20
|
+
export declare function unlockVault(passphrase?: string): Promise<Uint8Array>;
|
|
21
|
+
export declare function loadVaultDoc(): Promise<VaultDocument>;
|
|
22
|
+
export declare function addSecret(name: string, value: string, passphrase?: string): Promise<void>;
|
|
23
|
+
export declare function getSecret(name: string, options?: {
|
|
24
|
+
passphrase?: string;
|
|
25
|
+
cwd?: string;
|
|
26
|
+
enforceScope?: boolean;
|
|
27
|
+
}): Promise<string>;
|
|
28
|
+
export declare function listSecrets(passphrase?: string): Promise<string[]>;
|
|
29
|
+
export declare function removeSecret(name: string, passphrase?: string): Promise<void>;
|
|
30
|
+
export declare function buildEnvForRun(keyNames: string[], options?: {
|
|
31
|
+
passphrase?: string;
|
|
32
|
+
cwd?: string;
|
|
33
|
+
}): Promise<Record<string, string>>;
|
|
34
|
+
export declare function lock(): void;
|
|
35
|
+
export { isLocked };
|