@axtary/cli 0.1.0 → 0.3.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/README.md +135 -12
- package/dist/bin.js +10 -0
- package/dist/bin.js.map +1 -1
- package/dist/claude-code-hook.d.ts +20 -0
- package/dist/claude-code-hook.d.ts.map +1 -1
- package/dist/claude-code-hook.js +114 -34
- package/dist/claude-code-hook.js.map +1 -1
- package/dist/codex-hook.d.ts +48 -0
- package/dist/codex-hook.d.ts.map +1 -0
- package/dist/codex-hook.js +259 -0
- package/dist/codex-hook.js.map +1 -0
- package/dist/credentials.d.ts +101 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +277 -0
- package/dist/credentials.js.map +1 -0
- package/dist/cursor-hook.d.ts +51 -0
- package/dist/cursor-hook.d.ts.map +1 -0
- package/dist/cursor-hook.js +214 -0
- package/dist/cursor-hook.js.map +1 -0
- package/dist/hook-install.d.ts +21 -0
- package/dist/hook-install.d.ts.map +1 -0
- package/dist/hook-install.js +179 -0
- package/dist/hook-install.js.map +1 -0
- package/dist/index.d.ts +877 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6592 -1048
- package/dist/index.js.map +1 -1
- package/dist/mcp-fs-normalize.d.ts +18 -0
- package/dist/mcp-fs-normalize.d.ts.map +1 -0
- package/dist/mcp-fs-normalize.js +39 -0
- package/dist/mcp-fs-normalize.js.map +1 -0
- package/dist/mcp-oauth.d.ts +67 -0
- package/dist/mcp-oauth.d.ts.map +1 -0
- package/dist/mcp-oauth.js +242 -0
- package/dist/mcp-oauth.js.map +1 -0
- package/dist/oauth.d.ts +90 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +339 -0
- package/dist/oauth.js.map +1 -0
- package/dist/render.d.ts +103 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +172 -0
- package/dist/render.js.map +1 -0
- package/dist/shell-file-normalize.d.ts +21 -0
- package/dist/shell-file-normalize.d.ts.map +1 -0
- package/dist/shell-file-normalize.js +269 -0
- package/dist/shell-file-normalize.js.map +1 -0
- package/package.json +14 -9
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { parseNormalizedAction, } from "@axtary/actionpass";
|
|
2
|
+
import { authorizeRank, DENY_RANK, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_PROXY_URL, errorReason, postAuthorize, workspaceRelativePath, } from "./claude-code-hook.js";
|
|
3
|
+
import { classifyMcpFilesystemTool } from "./mcp-fs-normalize.js";
|
|
4
|
+
import { classifyShellFileOps, } from "./shell-file-normalize.js";
|
|
5
|
+
export { classifyShellCommand, classifyShellFileOps } from "./shell-file-normalize.js";
|
|
6
|
+
// Codex CLI runtime-hook adapter (mega-plan M8.4). Codex calls a configured
|
|
7
|
+
// `PreToolUse` hook before it runs a tool; the hook reads a JSON payload on
|
|
8
|
+
// stdin and returns a decision. Codex's hook contract is Claude-Code-shaped —
|
|
9
|
+
// it emits the same `hookSpecificOutput.permissionDecision` object — so the
|
|
10
|
+
// output side mirrors the Claude hook exactly. Verified against the official
|
|
11
|
+
// Codex hooks docs (2026-06-26).
|
|
12
|
+
//
|
|
13
|
+
// What differs from Claude/Cursor: Codex's reliable hook surface is the SHELL
|
|
14
|
+
// (`Bash`) tool, whose `tool_input.command` is a shell command string — not a
|
|
15
|
+
// file path. So the core work here is mapping a file-touching shell command
|
|
16
|
+
// (and, best-effort, `apply_patch` edits and MCP filesystem tools) onto the
|
|
17
|
+
// same `github.contents.read/write` actions, so the existing secret/path policy
|
|
18
|
+
// applies. Codex docs note hooks fire reliably for shell, less so for
|
|
19
|
+
// apply_patch / most MCP tools — hence those are best-effort.
|
|
20
|
+
//
|
|
21
|
+
// Fail-closed: a step-up renders as a BLOCK on Codex (its PreToolUse contract
|
|
22
|
+
// documents only allow/deny; we do not rely on an unverified "ask"), and any
|
|
23
|
+
// error (bad payload / unreachable proxy) is an explicit deny.
|
|
24
|
+
export const CODEX_HOOK_SCHEMA_VERSION = "axtary.codex_hook.v0";
|
|
25
|
+
export function parseCodexHookPayload(payloadText) {
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = JSON.parse(payloadText);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error("codex_hook_payload_invalid_json");
|
|
32
|
+
}
|
|
33
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
34
|
+
throw new Error("codex_hook_payload_not_object");
|
|
35
|
+
}
|
|
36
|
+
const record = raw;
|
|
37
|
+
const toolName = record.tool_name;
|
|
38
|
+
if (typeof toolName !== "string" || toolName.length === 0) {
|
|
39
|
+
throw new Error("codex_hook_tool_name_required");
|
|
40
|
+
}
|
|
41
|
+
const toolInput = record.tool_input &&
|
|
42
|
+
typeof record.tool_input === "object" &&
|
|
43
|
+
!Array.isArray(record.tool_input)
|
|
44
|
+
? record.tool_input
|
|
45
|
+
: {};
|
|
46
|
+
return {
|
|
47
|
+
hookEventName: typeof record.hook_event_name === "string" ? record.hook_event_name : null,
|
|
48
|
+
sessionId: typeof record.session_id === "string" ? record.session_id : null,
|
|
49
|
+
cwd: typeof record.cwd === "string" ? record.cwd : null,
|
|
50
|
+
toolName,
|
|
51
|
+
toolInput,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const SHELL_TOOLS = new Set(["Bash", "shell", "local_shell"]);
|
|
55
|
+
// Codex apply_patch envelope headers name the files touched.
|
|
56
|
+
const APPLY_PATCH_FILE_RE = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s+(.+?)\s*$/gm;
|
|
57
|
+
function applyPatchPaths(command) {
|
|
58
|
+
const paths = [];
|
|
59
|
+
let match;
|
|
60
|
+
APPLY_PATCH_FILE_RE.lastIndex = 0;
|
|
61
|
+
while ((match = APPLY_PATCH_FILE_RE.exec(command))) {
|
|
62
|
+
paths.push(match[1]);
|
|
63
|
+
}
|
|
64
|
+
return paths;
|
|
65
|
+
}
|
|
66
|
+
function buildContentAction(op, payload, options, sourceTool) {
|
|
67
|
+
const path = workspaceRelativePath(op.path, payload.cwd);
|
|
68
|
+
const repoResource = options.repoResource ?? "repo:local/workspace";
|
|
69
|
+
const taskId = payload.sessionId
|
|
70
|
+
? `codex:${payload.sessionId.slice(0, 12)}`
|
|
71
|
+
: "codex:session";
|
|
72
|
+
const base = {
|
|
73
|
+
actor: {
|
|
74
|
+
agentId: "agent:codex",
|
|
75
|
+
humanOwner: options.humanOwner ?? "user:local-developer",
|
|
76
|
+
runtime: "codex",
|
|
77
|
+
},
|
|
78
|
+
intent: {
|
|
79
|
+
taskId,
|
|
80
|
+
declaredGoal: `Codex ${payload.toolName} tool call`,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
if (op.kind === "read") {
|
|
84
|
+
return parseNormalizedAction({
|
|
85
|
+
...base,
|
|
86
|
+
capability: {
|
|
87
|
+
tool: "github.contents.read",
|
|
88
|
+
resource: repoResource,
|
|
89
|
+
payload: { path, source: "codex-hook", codexTool: sourceTool },
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return parseNormalizedAction({
|
|
94
|
+
...base,
|
|
95
|
+
capability: {
|
|
96
|
+
tool: "github.contents.write",
|
|
97
|
+
resource: repoResource,
|
|
98
|
+
payload: {
|
|
99
|
+
path,
|
|
100
|
+
branch: options.branch ?? "workspace",
|
|
101
|
+
source: "codex-hook",
|
|
102
|
+
codexTool: sourceTool,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
export function normalizeCodexToolCall(payload, options = {}) {
|
|
108
|
+
// Shell (the reliable Codex surface): map every file op a command performs,
|
|
109
|
+
// so `cp secret dest` governs the source read AND the destination write.
|
|
110
|
+
if (SHELL_TOOLS.has(payload.toolName)) {
|
|
111
|
+
const command = typeof payload.toolInput.command === "string"
|
|
112
|
+
? payload.toolInput.command
|
|
113
|
+
: "";
|
|
114
|
+
const ops = command ? classifyShellFileOps(command) : [];
|
|
115
|
+
if (ops.length === 0) {
|
|
116
|
+
return { mapped: false, reason: `codex_shell_no_file_op:${payload.toolName}` };
|
|
117
|
+
}
|
|
118
|
+
return mappedActions(ops.map((op) => buildContentAction(op, payload, options, payload.toolName)));
|
|
119
|
+
}
|
|
120
|
+
// apply_patch (best-effort): the envelope names every file it edits; govern
|
|
121
|
+
// all of them (the run loop denies if any is denied).
|
|
122
|
+
if (payload.toolName === "apply_patch") {
|
|
123
|
+
const command = typeof payload.toolInput.command === "string"
|
|
124
|
+
? payload.toolInput.command
|
|
125
|
+
: "";
|
|
126
|
+
const paths = command ? applyPatchPaths(command) : [];
|
|
127
|
+
if (paths.length === 0) {
|
|
128
|
+
return { mapped: false, reason: "codex_apply_patch_no_files" };
|
|
129
|
+
}
|
|
130
|
+
return mappedActions(paths.map((path) => buildContentAction({ kind: "write", path }, payload, options, payload.toolName)));
|
|
131
|
+
}
|
|
132
|
+
// MCP filesystem tools (best-effort; Codex MCP hooks fire less reliably).
|
|
133
|
+
const classified = classifyMcpFilesystemTool(payload.toolName);
|
|
134
|
+
if (classified) {
|
|
135
|
+
const rawPath = payload.toolInput[classified.pathField];
|
|
136
|
+
if (typeof rawPath === "string" && rawPath.length > 0) {
|
|
137
|
+
return mappedActions([
|
|
138
|
+
buildContentAction({ kind: classified.kind, path: rawPath }, payload, options, payload.toolName),
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { mapped: false, reason: `codex_tool_unmapped:${payload.toolName}` };
|
|
143
|
+
}
|
|
144
|
+
function mappedActions(actions) {
|
|
145
|
+
return { mapped: true, action: actions[0], actions };
|
|
146
|
+
}
|
|
147
|
+
export async function runCodexHook(input) {
|
|
148
|
+
let payload;
|
|
149
|
+
try {
|
|
150
|
+
payload = parseCodexHookPayload(input.payloadText);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
return decisionResult(null, null, "deny", [
|
|
154
|
+
`Axtary blocked this tool call: hook payload could not be parsed (${errorReason(error)}). Protected actions fail closed.`,
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
const normalization = normalizeCodexToolCall(payload, {
|
|
158
|
+
repoResource: input.repoResource,
|
|
159
|
+
humanOwner: input.humanOwner,
|
|
160
|
+
branch: input.branch,
|
|
161
|
+
});
|
|
162
|
+
if (!normalization.mapped) {
|
|
163
|
+
return {
|
|
164
|
+
schemaVersion: CODEX_HOOK_SCHEMA_VERSION,
|
|
165
|
+
status: "no_opinion",
|
|
166
|
+
tool: payload.toolName,
|
|
167
|
+
normalizedTool: null,
|
|
168
|
+
reason: normalization.reason,
|
|
169
|
+
output: null,
|
|
170
|
+
exitCode: 0,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const proxyUrl = (input.proxyUrl ?? DEFAULT_PROXY_URL).replace(/\/$/, "");
|
|
174
|
+
const fetchImpl = input.fetch ?? globalThis.fetch;
|
|
175
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
|
|
176
|
+
// Authorize every governed op; the most restrictive decision wins
|
|
177
|
+
// (deny > step_up > allow) and a hard deny short-circuits. Fail-closed.
|
|
178
|
+
let best = null;
|
|
179
|
+
for (const action of normalization.actions) {
|
|
180
|
+
let response;
|
|
181
|
+
try {
|
|
182
|
+
response = await postAuthorize(fetchImpl, `${proxyUrl}/authorize`, action, timeoutMs);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
return decisionResult(payload.toolName, action.capability.tool, "deny", [
|
|
186
|
+
`Axtary blocked ${action.capability.tool}: local proxy unreachable at ${proxyUrl} (${errorReason(error)}).`,
|
|
187
|
+
"Start it with: axtary proxy --config axtary.yml",
|
|
188
|
+
]);
|
|
189
|
+
}
|
|
190
|
+
const rank = authorizeRank(response.status);
|
|
191
|
+
if (!best || rank > best.rank) {
|
|
192
|
+
best = { action, response, rank };
|
|
193
|
+
}
|
|
194
|
+
if (rank === DENY_RANK)
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
const response = best.response;
|
|
198
|
+
const normalizedTool = best.action.capability.tool;
|
|
199
|
+
const reasons = response.decision?.reasons ?? [];
|
|
200
|
+
const hints = response.hints ?? [];
|
|
201
|
+
const summary = response.explanation?.summary ?? reasons.join(", ");
|
|
202
|
+
const ledgerLine = typeof response.ledger?.lineNumber === "number"
|
|
203
|
+
? ` Ledger line ${response.ledger.lineNumber}.`
|
|
204
|
+
: "";
|
|
205
|
+
if (response.status === "authorized") {
|
|
206
|
+
// Live-verified against Codex CLI v0.142.3: emitting
|
|
207
|
+
// `permissionDecision:"allow"` errors the hook ("unsupported
|
|
208
|
+
// permissionDecision:allow") and Codex then fails open. For an allow we emit
|
|
209
|
+
// NO decision so Codex proceeds normally; the decision is already recorded by
|
|
210
|
+
// the /authorize call above (only `deny` is honored on PreToolUse).
|
|
211
|
+
return {
|
|
212
|
+
schemaVersion: CODEX_HOOK_SCHEMA_VERSION,
|
|
213
|
+
status: "allow",
|
|
214
|
+
tool: payload.toolName,
|
|
215
|
+
normalizedTool,
|
|
216
|
+
reason: `Axtary authorized ${normalizedTool}: ${summary}${ledgerLine}`,
|
|
217
|
+
output: null,
|
|
218
|
+
exitCode: 0,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (response.status === "step_up") {
|
|
222
|
+
// Codex PreToolUse honors only `deny`; we block step-up (fail closed) rather
|
|
223
|
+
// than rely on an unverified interactive "ask", and point to the
|
|
224
|
+
// payload-bound approval path.
|
|
225
|
+
return decisionResult(payload.toolName, normalizedTool, "step_up", [
|
|
226
|
+
`Axtary blocked ${normalizedTool} pending human step-up approval: ${summary}`,
|
|
227
|
+
...hints,
|
|
228
|
+
"Approve the exact payload via the Axtary dashboard / proxy approval path before re-running.",
|
|
229
|
+
ledgerLine.trim(),
|
|
230
|
+
]);
|
|
231
|
+
}
|
|
232
|
+
return decisionResult(payload.toolName, normalizedTool, "deny", [
|
|
233
|
+
`Axtary blocked ${normalizedTool}: ${summary}`,
|
|
234
|
+
...hints,
|
|
235
|
+
ledgerLine.trim(),
|
|
236
|
+
]);
|
|
237
|
+
}
|
|
238
|
+
// Codex PreToolUse honors only `permissionDecision:"deny"` (allow/ask are
|
|
239
|
+
// rejected as unsupported, v0.142.3), so every blocking decision — an explicit
|
|
240
|
+
// deny, a fail-closed step-up, or an error — renders as a deny.
|
|
241
|
+
function decisionResult(tool, normalizedTool, status, reasonParts) {
|
|
242
|
+
const reason = reasonParts.filter((part) => part.length > 0).join(" ");
|
|
243
|
+
return {
|
|
244
|
+
schemaVersion: CODEX_HOOK_SCHEMA_VERSION,
|
|
245
|
+
status,
|
|
246
|
+
tool,
|
|
247
|
+
normalizedTool,
|
|
248
|
+
reason,
|
|
249
|
+
output: JSON.stringify({
|
|
250
|
+
hookSpecificOutput: {
|
|
251
|
+
hookEventName: "PreToolUse",
|
|
252
|
+
permissionDecision: "deny",
|
|
253
|
+
permissionDecisionReason: reason,
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
exitCode: 0,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
//# sourceMappingURL=codex-hook.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"codex-hook.js","sourceRoot":"","sources":["../src/codex-hook.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
export declare const CREDENTIALS_SCHEMA_VERSION = "axtary.credentials.v0";
|
|
3
|
+
export type CredentialSecretBackendName = "keychain" | "file";
|
|
4
|
+
/** Non-secret record of a connected provider. Safe to print / show in a UI. */
|
|
5
|
+
export type CredentialMetadata = {
|
|
6
|
+
provider: string;
|
|
7
|
+
type: "oauth" | "token";
|
|
8
|
+
source: "oauth" | "manual";
|
|
9
|
+
scopes: string[];
|
|
10
|
+
obtainedAt: string;
|
|
11
|
+
expiresAt: string | null;
|
|
12
|
+
/** A non-secret label like a Slack team id or workspace name. */
|
|
13
|
+
account: string | null;
|
|
14
|
+
/** Non-secret provider routing data, for example Jira cloudId and siteUrl. */
|
|
15
|
+
context?: Record<string, string>;
|
|
16
|
+
secretBackend: CredentialSecretBackendName;
|
|
17
|
+
};
|
|
18
|
+
/** Full credential including the secret material. Never logged or serialized to status output. */
|
|
19
|
+
export type StoredCredential = CredentialMetadata & {
|
|
20
|
+
accessToken: string;
|
|
21
|
+
refreshToken: string | null;
|
|
22
|
+
};
|
|
23
|
+
export type CredentialSecret = {
|
|
24
|
+
accessToken: string;
|
|
25
|
+
refreshToken: string | null;
|
|
26
|
+
};
|
|
27
|
+
/** Pluggable secret storage so the keychain can be swapped for a file (or an in-memory fake in tests). */
|
|
28
|
+
export type CredentialSecretBackend = {
|
|
29
|
+
readonly name: CredentialSecretBackendName;
|
|
30
|
+
get(provider: string): Promise<CredentialSecret | null>;
|
|
31
|
+
set(provider: string, secret: CredentialSecret): Promise<void>;
|
|
32
|
+
delete(provider: string): Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
/** True when the macOS `security` keychain CLI is usable. */
|
|
35
|
+
export declare function macKeychainAvailable(platform?: NodeJS.Platform, run?: typeof spawnSync): boolean;
|
|
36
|
+
/** macOS keychain-backed secret store (generic passwords under a per-provider service). */
|
|
37
|
+
export declare function createMacKeychainSecretBackend(run?: typeof spawnSync): CredentialSecretBackend;
|
|
38
|
+
export type CredentialStoreOptions = {
|
|
39
|
+
baseDir?: string;
|
|
40
|
+
secretBackend?: CredentialSecretBackend;
|
|
41
|
+
platform?: NodeJS.Platform;
|
|
42
|
+
run?: typeof spawnSync;
|
|
43
|
+
};
|
|
44
|
+
export declare class CredentialStore {
|
|
45
|
+
private readonly baseDir;
|
|
46
|
+
private readonly filePath;
|
|
47
|
+
private readonly secretBackend;
|
|
48
|
+
constructor(options?: CredentialStoreOptions);
|
|
49
|
+
/** The backend chosen for new writes (so callers can report where secrets land). */
|
|
50
|
+
secretBackendName(): CredentialSecretBackendName;
|
|
51
|
+
get(provider: string): Promise<StoredCredential | null>;
|
|
52
|
+
set(input: {
|
|
53
|
+
provider: string;
|
|
54
|
+
type: CredentialMetadata["type"];
|
|
55
|
+
source: CredentialMetadata["source"];
|
|
56
|
+
accessToken: string;
|
|
57
|
+
refreshToken?: string | null;
|
|
58
|
+
scopes?: string[];
|
|
59
|
+
expiresAt?: string | null;
|
|
60
|
+
account?: string | null;
|
|
61
|
+
context?: Record<string, string>;
|
|
62
|
+
}): Promise<CredentialMetadata>;
|
|
63
|
+
/**
|
|
64
|
+
* Write the secret to the preferred backend, then read it back and confirm it
|
|
65
|
+
* round-trips. If the keychain write did not actually persist a readable
|
|
66
|
+
* secret — e.g. macOS `security add-generic-password -w` reads the password
|
|
67
|
+
* from the controlling TTY rather than our piped stdin, storing garbage — fall
|
|
68
|
+
* back to the portable 0600 file backend instead of silently storing an
|
|
69
|
+
* unreadable credential. The secret value is never placed on a process argv.
|
|
70
|
+
*/
|
|
71
|
+
private writeSecretVerified;
|
|
72
|
+
delete(provider: string): Promise<boolean>;
|
|
73
|
+
/** Metadata only — never returns secret material. */
|
|
74
|
+
list(): Promise<CredentialMetadata[]>;
|
|
75
|
+
private fileSecretBackend;
|
|
76
|
+
private read;
|
|
77
|
+
private write;
|
|
78
|
+
}
|
|
79
|
+
/** True once an OAuth credential has lapsed (with a small safety skew). */
|
|
80
|
+
export declare function credentialExpired(credential: Pick<StoredCredential, "expiresAt">, now?: number, skewMs?: number): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a provider token with the broker taking precedence over a raw env
|
|
83
|
+
* var. A connected, non-expired stored credential wins; otherwise we fall back
|
|
84
|
+
* to `env[tokenEnv]` (which itself may be populated by a secret manager — see
|
|
85
|
+
* https://docs.axtary.com/credentials). Returns the token and where it came from.
|
|
86
|
+
*/
|
|
87
|
+
export declare function resolveProviderToken(input: {
|
|
88
|
+
provider: string;
|
|
89
|
+
tokenEnv: string;
|
|
90
|
+
env: Record<string, string | undefined>;
|
|
91
|
+
store: CredentialStore | null;
|
|
92
|
+
now?: number;
|
|
93
|
+
}): Promise<{
|
|
94
|
+
token: string | undefined;
|
|
95
|
+
source: "broker" | "env" | "missing";
|
|
96
|
+
}>;
|
|
97
|
+
export declare function loadCredentialStore(options?: CredentialStoreOptions): CredentialStore;
|
|
98
|
+
export declare function credentialsBaseDir(): string;
|
|
99
|
+
export declare function credentialsLocation(baseDir?: string): string;
|
|
100
|
+
export declare function removeCredentialsFile(baseDir: string): Promise<void>;
|
|
101
|
+
//# sourceMappingURL=credentials.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
// Axtary local credential broker.
|
|
6
|
+
//
|
|
7
|
+
// PRD §10.1: the credential broker runs locally so provider secrets stay on the
|
|
8
|
+
// customer's machine and never reach the hosted control plane. This module is
|
|
9
|
+
// that broker's storage layer: it keeps a 0600 metadata file (which providers
|
|
10
|
+
// are connected, scopes, expiry — never the secret) and stores the actual access
|
|
11
|
+
// tokens in the OS keychain when available, falling back to the same 0600 file.
|
|
12
|
+
//
|
|
13
|
+
// The metadata/secret split means `axtary connections` (status) is secret-safe
|
|
14
|
+
// by construction: it only ever reads metadata, never a token.
|
|
15
|
+
export const CREDENTIALS_SCHEMA_VERSION = "axtary.credentials.v0";
|
|
16
|
+
const KEYCHAIN_SERVICE_PREFIX = "axtary:credential:";
|
|
17
|
+
const KEYCHAIN_ACCOUNT = "axtary";
|
|
18
|
+
function defaultBaseDir() {
|
|
19
|
+
return process.env.AXTARY_HOME ?? join(homedir(), ".axtary");
|
|
20
|
+
}
|
|
21
|
+
function credentialsFilePath(baseDir) {
|
|
22
|
+
return join(baseDir, "credentials.json");
|
|
23
|
+
}
|
|
24
|
+
/** True when the macOS `security` keychain CLI is usable. */
|
|
25
|
+
export function macKeychainAvailable(platform = process.platform, run = spawnSync) {
|
|
26
|
+
if (platform !== "darwin")
|
|
27
|
+
return false;
|
|
28
|
+
const probe = run("security", ["help"], { stdio: "ignore" });
|
|
29
|
+
return probe.status === 0 || probe.status === 1; // `security help` exits non-zero but exists
|
|
30
|
+
}
|
|
31
|
+
/** macOS keychain-backed secret store (generic passwords under a per-provider service). */
|
|
32
|
+
export function createMacKeychainSecretBackend(run = spawnSync) {
|
|
33
|
+
const service = (provider) => `${KEYCHAIN_SERVICE_PREFIX}${provider}`;
|
|
34
|
+
return {
|
|
35
|
+
name: "keychain",
|
|
36
|
+
async get(provider) {
|
|
37
|
+
const result = run("security", ["find-generic-password", "-a", KEYCHAIN_ACCOUNT, "-s", service(provider), "-w"], { encoding: "utf8" });
|
|
38
|
+
if (result.status !== 0 || typeof result.stdout !== "string")
|
|
39
|
+
return null;
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(result.stdout.trim());
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
async set(provider, secret) {
|
|
48
|
+
// Pass the secret on stdin, never on argv. `-w` with no value makes
|
|
49
|
+
// `security` read the password from the prompt (here, stdin), which
|
|
50
|
+
// prompts twice ("password" + "retype"), so the payload is written twice.
|
|
51
|
+
// This keeps the token off the `security` process command line, where
|
|
52
|
+
// `ps` would otherwise expose it to any same-user process. `-U` updates an
|
|
53
|
+
// existing item.
|
|
54
|
+
const payload = JSON.stringify(secret);
|
|
55
|
+
const result = run("security", [
|
|
56
|
+
"add-generic-password",
|
|
57
|
+
"-U",
|
|
58
|
+
"-a",
|
|
59
|
+
KEYCHAIN_ACCOUNT,
|
|
60
|
+
"-s",
|
|
61
|
+
service(provider),
|
|
62
|
+
"-w",
|
|
63
|
+
], { input: `${payload}\n${payload}\n`, stdio: ["pipe", "ignore", "ignore"] });
|
|
64
|
+
if (result.status !== 0) {
|
|
65
|
+
throw new Error("credential_keychain_write_failed");
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
async delete(provider) {
|
|
69
|
+
run("security", ["delete-generic-password", "-a", KEYCHAIN_ACCOUNT, "-s", service(provider)], { stdio: "ignore" });
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the secret backend: explicit override wins; otherwise keychain on
|
|
75
|
+
* macOS when available, else the 0600 file. `AXTARY_CREDENTIAL_BACKEND=file`
|
|
76
|
+
* forces the portable file backend (useful in CI / containers).
|
|
77
|
+
*/
|
|
78
|
+
function resolveSecretBackend(options, fileBackend) {
|
|
79
|
+
if (options.secretBackend)
|
|
80
|
+
return options.secretBackend;
|
|
81
|
+
if (process.env.AXTARY_CREDENTIAL_BACKEND === "file")
|
|
82
|
+
return fileBackend;
|
|
83
|
+
if (macKeychainAvailable(options.platform, options.run)) {
|
|
84
|
+
return createMacKeychainSecretBackend(options.run);
|
|
85
|
+
}
|
|
86
|
+
return fileBackend;
|
|
87
|
+
}
|
|
88
|
+
export class CredentialStore {
|
|
89
|
+
baseDir;
|
|
90
|
+
filePath;
|
|
91
|
+
secretBackend;
|
|
92
|
+
constructor(options = {}) {
|
|
93
|
+
this.baseDir = options.baseDir ?? defaultBaseDir();
|
|
94
|
+
this.filePath = credentialsFilePath(this.baseDir);
|
|
95
|
+
this.secretBackend = resolveSecretBackend(options, this.fileSecretBackend());
|
|
96
|
+
}
|
|
97
|
+
/** The backend chosen for new writes (so callers can report where secrets land). */
|
|
98
|
+
secretBackendName() {
|
|
99
|
+
return this.secretBackend.name;
|
|
100
|
+
}
|
|
101
|
+
async get(provider) {
|
|
102
|
+
const file = await this.read();
|
|
103
|
+
const metadata = file.credentials[provider];
|
|
104
|
+
if (!metadata)
|
|
105
|
+
return null;
|
|
106
|
+
const backend = metadata.secretBackend === this.secretBackend.name
|
|
107
|
+
? this.secretBackend
|
|
108
|
+
: metadata.secretBackend === "file"
|
|
109
|
+
? this.fileSecretBackend()
|
|
110
|
+
: createMacKeychainSecretBackend();
|
|
111
|
+
const secret = await backend.get(provider);
|
|
112
|
+
if (!secret)
|
|
113
|
+
return null;
|
|
114
|
+
return { ...metadata, accessToken: secret.accessToken, refreshToken: secret.refreshToken };
|
|
115
|
+
}
|
|
116
|
+
async set(input) {
|
|
117
|
+
const usedBackend = await this.writeSecretVerified(input.provider, {
|
|
118
|
+
accessToken: input.accessToken,
|
|
119
|
+
refreshToken: input.refreshToken ?? null,
|
|
120
|
+
});
|
|
121
|
+
const metadata = {
|
|
122
|
+
provider: input.provider,
|
|
123
|
+
type: input.type,
|
|
124
|
+
source: input.source,
|
|
125
|
+
scopes: input.scopes ?? [],
|
|
126
|
+
obtainedAt: new Date().toISOString(),
|
|
127
|
+
expiresAt: input.expiresAt ?? null,
|
|
128
|
+
account: input.account ?? null,
|
|
129
|
+
context: input.context,
|
|
130
|
+
secretBackend: usedBackend.name,
|
|
131
|
+
};
|
|
132
|
+
const file = await this.read();
|
|
133
|
+
file.credentials[input.provider] = metadata;
|
|
134
|
+
if (usedBackend.name !== "file" && file.secrets) {
|
|
135
|
+
delete file.secrets[input.provider];
|
|
136
|
+
}
|
|
137
|
+
await this.write(file);
|
|
138
|
+
return metadata;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Write the secret to the preferred backend, then read it back and confirm it
|
|
142
|
+
* round-trips. If the keychain write did not actually persist a readable
|
|
143
|
+
* secret — e.g. macOS `security add-generic-password -w` reads the password
|
|
144
|
+
* from the controlling TTY rather than our piped stdin, storing garbage — fall
|
|
145
|
+
* back to the portable 0600 file backend instead of silently storing an
|
|
146
|
+
* unreadable credential. The secret value is never placed on a process argv.
|
|
147
|
+
*/
|
|
148
|
+
async writeSecretVerified(provider, secret) {
|
|
149
|
+
const primary = this.secretBackend;
|
|
150
|
+
try {
|
|
151
|
+
await primary.set(provider, secret);
|
|
152
|
+
const readBack = await primary.get(provider);
|
|
153
|
+
if (readBack &&
|
|
154
|
+
readBack.accessToken === secret.accessToken &&
|
|
155
|
+
(readBack.refreshToken ?? null) === (secret.refreshToken ?? null)) {
|
|
156
|
+
return primary;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Fall through to the file backend below.
|
|
161
|
+
}
|
|
162
|
+
if (primary.name === "file") {
|
|
163
|
+
throw new Error("credential_secret_write_failed");
|
|
164
|
+
}
|
|
165
|
+
// The preferred backend did not round-trip. Remove any partial item it left,
|
|
166
|
+
// then store the secret in the verifiable 0600 file backend.
|
|
167
|
+
try {
|
|
168
|
+
await primary.delete(provider);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// best-effort cleanup
|
|
172
|
+
}
|
|
173
|
+
const fileBackend = this.fileSecretBackend();
|
|
174
|
+
await fileBackend.set(provider, secret);
|
|
175
|
+
return fileBackend;
|
|
176
|
+
}
|
|
177
|
+
async delete(provider) {
|
|
178
|
+
const file = await this.read();
|
|
179
|
+
const metadata = file.credentials[provider];
|
|
180
|
+
if (!metadata)
|
|
181
|
+
return false;
|
|
182
|
+
const backend = metadata.secretBackend === "file" ? this.fileSecretBackend() : createMacKeychainSecretBackend();
|
|
183
|
+
await backend.delete(provider);
|
|
184
|
+
delete file.credentials[provider];
|
|
185
|
+
if (file.secrets)
|
|
186
|
+
delete file.secrets[provider];
|
|
187
|
+
await this.write(file);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
/** Metadata only — never returns secret material. */
|
|
191
|
+
async list() {
|
|
192
|
+
const file = await this.read();
|
|
193
|
+
return Object.values(file.credentials).sort((a, b) => a.provider.localeCompare(b.provider));
|
|
194
|
+
}
|
|
195
|
+
fileSecretBackend() {
|
|
196
|
+
return {
|
|
197
|
+
name: "file",
|
|
198
|
+
get: async (provider) => {
|
|
199
|
+
const file = await this.read();
|
|
200
|
+
return file.secrets?.[provider] ?? null;
|
|
201
|
+
},
|
|
202
|
+
set: async (provider, secret) => {
|
|
203
|
+
const file = await this.read();
|
|
204
|
+
file.secrets = file.secrets ?? {};
|
|
205
|
+
file.secrets[provider] = secret;
|
|
206
|
+
await this.write(file);
|
|
207
|
+
},
|
|
208
|
+
delete: async (provider) => {
|
|
209
|
+
const file = await this.read();
|
|
210
|
+
if (file.secrets) {
|
|
211
|
+
delete file.secrets[provider];
|
|
212
|
+
await this.write(file);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
async read() {
|
|
218
|
+
try {
|
|
219
|
+
const raw = await readFile(this.filePath, "utf8");
|
|
220
|
+
const parsed = JSON.parse(raw);
|
|
221
|
+
return {
|
|
222
|
+
version: parsed.version ?? CREDENTIALS_SCHEMA_VERSION,
|
|
223
|
+
credentials: parsed.credentials ?? {},
|
|
224
|
+
secrets: parsed.secrets,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (error.code === "ENOENT") {
|
|
229
|
+
return { version: CREDENTIALS_SCHEMA_VERSION, credentials: {} };
|
|
230
|
+
}
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async write(file) {
|
|
235
|
+
await mkdir(this.baseDir, { recursive: true, mode: 0o700 });
|
|
236
|
+
await writeFile(this.filePath, `${JSON.stringify({ ...file, version: CREDENTIALS_SCHEMA_VERSION }, null, 2)}\n`, { mode: 0o600 });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** True once an OAuth credential has lapsed (with a small safety skew). */
|
|
240
|
+
export function credentialExpired(credential, now = Date.now(), skewMs = 30_000) {
|
|
241
|
+
if (!credential.expiresAt)
|
|
242
|
+
return false;
|
|
243
|
+
return new Date(credential.expiresAt).getTime() - skewMs <= now;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Resolve a provider token with the broker taking precedence over a raw env
|
|
247
|
+
* var. A connected, non-expired stored credential wins; otherwise we fall back
|
|
248
|
+
* to `env[tokenEnv]` (which itself may be populated by a secret manager — see
|
|
249
|
+
* https://docs.axtary.com/credentials). Returns the token and where it came from.
|
|
250
|
+
*/
|
|
251
|
+
export async function resolveProviderToken(input) {
|
|
252
|
+
if (input.store) {
|
|
253
|
+
const credential = await input.store.get(input.provider);
|
|
254
|
+
if (credential && !credentialExpired(credential, input.now)) {
|
|
255
|
+
return { token: credential.accessToken, source: "broker" };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const envToken = input.env[input.tokenEnv];
|
|
259
|
+
if (envToken)
|
|
260
|
+
return { token: envToken, source: "env" };
|
|
261
|
+
return { token: undefined, source: "missing" };
|
|
262
|
+
}
|
|
263
|
+
export function loadCredentialStore(options = {}) {
|
|
264
|
+
return new CredentialStore(options);
|
|
265
|
+
}
|
|
266
|
+
export function credentialsBaseDir() {
|
|
267
|
+
return defaultBaseDir();
|
|
268
|
+
}
|
|
269
|
+
export function credentialsLocation(baseDir = defaultBaseDir()) {
|
|
270
|
+
return credentialsFilePath(baseDir);
|
|
271
|
+
}
|
|
272
|
+
// Re-exported for callers that need to clean up a base dir in tests.
|
|
273
|
+
export async function removeCredentialsFile(baseDir) {
|
|
274
|
+
await rm(credentialsFilePath(baseDir), { force: true });
|
|
275
|
+
await rm(dirname(credentialsFilePath(baseDir)), { force: true, recursive: true });
|
|
276
|
+
}
|
|
277
|
+
//# sourceMappingURL=credentials.js.map
|