hakira-mcp 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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +198 -0
  3. package/dist/auth/credentials.d.ts +12 -0
  4. package/dist/auth/credentials.js +61 -0
  5. package/dist/auth/loopback.d.ts +26 -0
  6. package/dist/auth/loopback.js +178 -0
  7. package/dist/config.d.ts +8 -0
  8. package/dist/config.js +21 -0
  9. package/dist/git/exec.d.ts +23 -0
  10. package/dist/git/exec.js +75 -0
  11. package/dist/git/metadata.d.ts +20 -0
  12. package/dist/git/metadata.js +40 -0
  13. package/dist/git/repo-key.d.ts +7 -0
  14. package/dist/git/repo-key.js +91 -0
  15. package/dist/http/cp-client.d.ts +182 -0
  16. package/dist/http/cp-client.js +196 -0
  17. package/dist/http/errors.d.ts +28 -0
  18. package/dist/http/errors.js +47 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.js +52 -0
  21. package/dist/log.d.ts +5 -0
  22. package/dist/log.js +11 -0
  23. package/dist/resources/finding.d.ts +3 -0
  24. package/dist/resources/finding.js +24 -0
  25. package/dist/tools/cancel.d.ts +3 -0
  26. package/dist/tools/cancel.js +20 -0
  27. package/dist/tools/context.d.ts +9 -0
  28. package/dist/tools/context.js +1 -0
  29. package/dist/tools/get-audit-events.d.ts +3 -0
  30. package/dist/tools/get-audit-events.js +32 -0
  31. package/dist/tools/get-finding.d.ts +3 -0
  32. package/dist/tools/get-finding.js +15 -0
  33. package/dist/tools/get-findings.d.ts +3 -0
  34. package/dist/tools/get-findings.js +31 -0
  35. package/dist/tools/get-status.d.ts +3 -0
  36. package/dist/tools/get-status.js +13 -0
  37. package/dist/tools/list-audits.d.ts +3 -0
  38. package/dist/tools/list-audits.js +35 -0
  39. package/dist/tools/list-workspaces.d.ts +3 -0
  40. package/dist/tools/list-workspaces.js +24 -0
  41. package/dist/tools/resolve-run-mode.d.ts +31 -0
  42. package/dist/tools/resolve-run-mode.js +84 -0
  43. package/dist/tools/start-audit.d.ts +3 -0
  44. package/dist/tools/start-audit.js +109 -0
  45. package/dist/tools/wrap.d.ts +13 -0
  46. package/dist/tools/wrap.js +89 -0
  47. package/dist/upload/presigned.d.ts +23 -0
  48. package/dist/upload/presigned.js +32 -0
  49. package/dist/upload/zip.d.ts +22 -0
  50. package/dist/upload/zip.js +182 -0
  51. package/dist/version.d.ts +1 -0
  52. package/dist/version.js +16 -0
  53. package/package.json +41 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hakira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # hakira-mcp
2
+
3
+ A local **stdio MCP server** that lets your coding agent (Claude Code, Cursor, Windsurf, …) trigger
4
+ **Hakira cloud security audits** of the repo you're working in — and read the findings back — without
5
+ leaving the editor.
6
+
7
+ It exposes eight tools and one resource:
8
+
9
+ | Tool | Cost | What it does |
10
+ |---|---|---|
11
+ | `start_audit` | **spends credits** | Zip the working tree (gitignore-respecting, secrets stripped), upload it, and start a cloud audit. Returns `{ audit_id, mode, do_not_poll, agent_instruction }` immediately. Asks background vs wait when the host supports MCP elicitation. |
12
+ | `get_audit_status` | free | Poll an audit: `queued → provisioning → running → ready` (or `canceled` / `error`). Full-scope audits also carry `full_project_estimate_usd`. |
13
+ | `get_audit_events` | free | Activity trail (coalesced assistant text, collapsed tool runs, progress, findings). No tool result bodies. Use when checking what Hakira is doing / has done. Omit `after` for the latest page. |
14
+ | `get_audit_findings` | free | Finding **summaries** only. Then call `get_finding` for critical/high or when explaining/fixing. |
15
+ | `get_finding` | free | Full finding (description, evidence, recommendation). Use after `get_audit_findings`. |
16
+ | `list_audits` | free | Recent audits/sessions across your account (`is_current` marks this local project). Optional `workspace_id` filter. |
17
+ | `list_workspaces` | free | All workspaces on your account; `is_current` marks this local project. |
18
+ | `cancel_audit` | free | Request cancellation of a running audit. |
19
+
20
+ Resource `hakira://finding/<id>` returns the same full detail as `get_finding` (kept for hosts that prefer resources).
21
+
22
+ > **⚠️ `start_audit` spends credits** — it's the only Hakira tool that does; the other seven are free.
23
+ > A whole-project (`full`) audit costs more than a changed-files (`diff`) audit. If your credit balance
24
+ > is too low the call fails immediately with a `payment_required` error carrying a top-up link, and
25
+ > **nothing is uploaded** — add credits and re-run.
26
+
27
+ ## Quickstart
28
+
29
+ 1. Add the server to your client config (see **Install** below) — browser login, no token.
30
+ **On Cursor, Add to Cursor is faster than pasting the prompt.**
31
+ 2. **Restart the client** so it launches the server (MCP config loads at start; no hot reload).
32
+ On Cursor after one-click install, toggle/enable in MCP settings is often enough.
33
+ 3. Ask your agent to run an audit — the first tool call opens your browser to authorize.
34
+ 4. Read the findings back in your editor.
35
+
36
+ ### Give this to your coding agent
37
+
38
+ Paste this into Claude Code or Cursor and it will set Hakira up and run your first audit — it asks what
39
+ to scan and waits for your go-ahead before the paid call. On Cursor, prefer **Add to Cursor** (Install)
40
+ for the install step, then paste this to run the first audit:
41
+
42
+ ```
43
+ Set up the Hakira security-audit MCP server for this project and run a first audit.
44
+
45
+ 1. Add this server to my MCP config (Claude Code: .mcp.json; Cursor:
46
+ .cursor/mcp.json) — do not overwrite other servers:
47
+ {
48
+ "mcpServers": {
49
+ "hakira": { "type": "stdio", "command": "npx", "args": ["-y", "hakira-mcp"] }
50
+ }
51
+ }
52
+ Then tell me to restart so the server loads.
53
+
54
+ 2. After I restart, ask me what to scan — the whole project (scope:"full") or only
55
+ my locally-changed files (scope:"diff") — and whether to narrow to a path or
56
+ point at a running URL. Then call the Hakira `start_audit` tool with my choice.
57
+ Do NOT pass `mode` unless I explicitly said "background" or "wait" — omit it so
58
+ the host can ask (or default background). start_audit is the only Hakira tool
59
+ that spends credits, so WAIT for my explicit go-ahead before calling it. The
60
+ first call will open my browser to authorize this machine.
61
+ - If start_audit returns a `payment_required` error, show me the buy_credits_url
62
+ from that error and stop — I need to add credits before the audit can run.
63
+
64
+ 3. It returns { audit_id, status, mode, do_not_poll, agent_instruction }.
65
+ ALWAYS follow `agent_instruction` / `do_not_poll`:
66
+ - If do_not_poll is true (background): do NOT poll. Tell me the audit_id and
67
+ that I can ask later for status/findings. Continue helping with other work.
68
+ When I ask to check the audit / what Hakira is doing, call `get_audit_events`
69
+ (omit after for the latest page; page with after=next_after if has_more).
70
+ - If do_not_poll is false (wait): poll `get_audit_status` with that audit_id,
71
+ waiting poll_after_ms between calls, until status is "ready" (stop on
72
+ "canceled" or "error"). Then call `get_audit_findings`.
73
+ Hosts that support MCP elicitation may ask me background vs wait before the
74
+ audit starts; if I cancel that prompt, do not start.
75
+
76
+ 4. When findings are fetched (wait mode, or later when I ask): summarize by
77
+ severity. For any high/critical — or when I ask what findings are about —
78
+ call `get_finding` for each of those ids and show evidence + recommended fix.
79
+ If there are no findings, tell me the audit came back clean.
80
+ ```
81
+
82
+ ## Install
83
+
84
+ The server runs via `npx` — no global install needed. **Browser login is the default** (no token in
85
+ config): the first tool call opens your browser to authorize this machine, then caches a credential in
86
+ `~/.hakira/credentials.json` (chmod 600).
87
+
88
+ ### Cursor (one-click)
89
+
90
+ Prefer Cursor’s official install deeplink (browser-login config, no token):
91
+
92
+ [Add to Cursor](https://cursor.com/install-mcp?name=hakira&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImhha2lyYS1tY3AiXX0%3D)
93
+
94
+ Or open the deeplink directly:
95
+
96
+ ```text
97
+ cursor://anysphere.cursor-deeplink/mcp/install?name=hakira&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImhha2lyYS1tY3AiXX0%3D
98
+ ```
99
+
100
+ Badge (for docs / landing pages):
101
+
102
+ ```html
103
+ <a href="https://cursor.com/install-mcp?name=hakira&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImhha2lyYS1tY3AiXX0%3D">
104
+ <img alt="Add to Cursor" src="https://cursor.com/deeplink/mcp-install-dark.svg" />
105
+ </a>
106
+ ```
107
+
108
+ After install, enable the server in Cursor MCP settings if it appears disabled. Manual
109
+ `.cursor/mcp.json` editing is still supported (table below).
110
+
111
+ | Client | Config file | Fallback-token env syntax |
112
+ |---|---|---|
113
+ | Claude Code | `.mcp.json` (project) or `~/.claude/.mcp.json` | `${HAKIRA_TOKEN}` |
114
+ | Cursor | `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` | `${env:HAKIRA_TOKEN}` |
115
+ | Windsurf | `~/.codeium/windsurf/mcp_config.json` (global only) | `${HAKIRA_TOKEN}` |
116
+
117
+ **Primary (browser login):**
118
+
119
+ ```jsonc
120
+ { "mcpServers": { "hakira": { "type": "stdio", "command": "npx", "args": ["-y", "hakira-mcp"] } } }
121
+ ```
122
+
123
+ **Headless / CI (no browser):** set a Personal Access Token instead. Mint one from the Hakira dashboard
124
+ and expose it as `HAKIRA_TOKEN` — the server uses it directly and never opens a browser:
125
+
126
+ ```jsonc
127
+ {
128
+ "mcpServers": {
129
+ "hakira": {
130
+ "type": "stdio",
131
+ "command": "npx",
132
+ "args": ["-y", "hakira-mcp"],
133
+ "env": { "HAKIRA_TOKEN": "${HAKIRA_TOKEN}" }
134
+ }
135
+ }
136
+ }
137
+ ```
138
+
139
+ On a headless machine with no `HAKIRA_TOKEN`, a tool call returns a structured `authorization_required`
140
+ message with a URL to open elsewhere — it never hangs.
141
+
142
+ ## Commands
143
+
144
+ - `hakira-mcp` — run the MCP server (what your client launches).
145
+ - `hakira-mcp logout` — delete the cached credential (`~/.hakira/credentials.json`).
146
+
147
+ ## Configuration (env)
148
+
149
+ | Var | Default | Purpose |
150
+ |---|---|---|
151
+ | `HAKIRA_TOKEN` | — | Personal Access Token. If set, used directly (fully headless; no browser, no cache). |
152
+ | `HAKIRA_API_URL` | `https://app.hakira.io/api` | Control-Plane API base (Netlify rewrites `/api/*`→CP). |
153
+ | `HAKIRA_WEB_URL` | `https://app.hakira.io` | App host for human SPA pages (e.g. `/billing`). |
154
+
155
+ ## Privacy — what leaves your machine
156
+
157
+ `start_audit` uploads a ZIP of your **working tree** (honoring `.gitignore`). It never ships your real
158
+ `.git`, and it strips a secret deny-list on top of `.gitignore`: `.env*`, `*.pem`, `*.key`, `id_rsa*`,
159
+ `*.p12`, `*.pfx`, `.ssh/`, `.aws/`, `.npmrc`, `*.keystore`. Add more patterns in a `.hakiraignore` file
160
+ at the repo root. Every run prints a one-line manifest (`uploading N files / M MB; excluded: …`) to
161
+ stderr and in the tool result, so you see exactly what left the machine before the paid run.
162
+
163
+ ## Dev / e2e (local stack)
164
+
165
+ Build the package, then point a client at the built binary against a local Control Plane:
166
+
167
+ ```jsonc
168
+ {
169
+ "mcpServers": {
170
+ "hakira": {
171
+ "type": "stdio",
172
+ "command": "node",
173
+ "args": ["packages/hakira-mcp/dist/index.js"],
174
+ "env": {
175
+ "HAKIRA_API_URL": "http://localhost:3000",
176
+ "HAKIRA_WEB_URL": "http://localhost:5173",
177
+ "HAKIRA_TOKEN": "<dev-seed PAT>"
178
+ }
179
+ }
180
+ }
181
+ }
182
+ ```
183
+
184
+ Restart the client session after each rebuild (no mid-session reload). Fast inner loop without a client:
185
+
186
+ ```bash
187
+ npm run build -w packages/hakira-mcp
188
+ npx @modelcontextprotocol/inspector --cli node packages/hakira-mcp/dist/index.js --method tools/list
189
+ ```
190
+
191
+ ## Develop
192
+
193
+ ```bash
194
+ npm run build -w packages/hakira-mcp # tsc → dist/ (+ chmod +x the bin)
195
+ npm test -w packages/hakira-mcp # unit + stdio smoke (build first for the smoke test)
196
+ ```
197
+
198
+ All logging goes to **stderr** — stdout is the JSON-RPC channel.
@@ -0,0 +1,12 @@
1
+ /** Delete the cached credential. Called by the 401 path (clear + re-auth once). */
2
+ export declare function clearCredentials(): void;
3
+ /**
4
+ * Resolve a bearer token:
5
+ * 1. HAKIRA_TOKEN env set → use it (path B, fully headless, no cache write).
6
+ * 2. cached credential for the current apiUrl → use it.
7
+ * 3. else run browser login(), cache, return.
8
+ * May throw AuthorizationRequiredError (headless) — the tool wrapper surfaces it.
9
+ */
10
+ export declare function resolveToken(): Promise<string>;
11
+ /** `hakira-mcp logout` subcommand body. */
12
+ export declare function logout(): void;
@@ -0,0 +1,61 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { config } from '../config.js';
5
+ import { log } from '../log.js';
6
+ import { login } from './loopback.js';
7
+ // Credential cache: ~/.hakira/credentials.json (chmod 600). resolveToken() is the
8
+ // SINGLE entry point every authed CpClient call goes through (ticket S4).
9
+ const HAKIRA_DIR = join(homedir(), '.hakira');
10
+ const CRED_PATH = join(HAKIRA_DIR, 'credentials.json');
11
+ function readCredentials() {
12
+ try {
13
+ if (!existsSync(CRED_PATH))
14
+ return null;
15
+ const parsed = JSON.parse(readFileSync(CRED_PATH, 'utf8'));
16
+ if (typeof parsed.access_token === 'string' && typeof parsed.api_url === 'string') {
17
+ return { access_token: parsed.access_token, api_url: parsed.api_url };
18
+ }
19
+ return null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function writeCredentials(creds) {
26
+ mkdirSync(HAKIRA_DIR, { recursive: true, mode: 0o700 });
27
+ const json = JSON.stringify(creds, null, 2);
28
+ writeFileSync(CRED_PATH, json, { mode: 0o600 });
29
+ chmodSync(CRED_PATH, 0o600); // belt-and-suspenders: umask can widen the create mode
30
+ }
31
+ /** Delete the cached credential. Called by the 401 path (clear + re-auth once). */
32
+ export function clearCredentials() {
33
+ try {
34
+ rmSync(CRED_PATH, { force: true });
35
+ }
36
+ catch {
37
+ /* ignore */
38
+ }
39
+ }
40
+ /**
41
+ * Resolve a bearer token:
42
+ * 1. HAKIRA_TOKEN env set → use it (path B, fully headless, no cache write).
43
+ * 2. cached credential for the current apiUrl → use it.
44
+ * 3. else run browser login(), cache, return.
45
+ * May throw AuthorizationRequiredError (headless) — the tool wrapper surfaces it.
46
+ */
47
+ export async function resolveToken() {
48
+ if (config.envToken)
49
+ return config.envToken;
50
+ const cached = readCredentials();
51
+ if (cached && cached.api_url === config.apiUrl)
52
+ return cached.access_token;
53
+ const token = await login();
54
+ writeCredentials({ access_token: token, api_url: config.apiUrl });
55
+ return token;
56
+ }
57
+ /** `hakira-mcp logout` subcommand body. */
58
+ export function logout() {
59
+ clearCredentials();
60
+ log.info('Logged out — cleared cached credentials at ~/.hakira/credentials.json');
61
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The client could not open a browser (SSH / CI / headless / spawn failed).
3
+ * Surfaced upward as a structured MCP result, NEVER a hang (00 §7, gotcha G8).
4
+ */
5
+ export declare class AuthorizationRequiredError extends Error {
6
+ readonly authorize_url: string;
7
+ readonly code = "authorization_required";
8
+ constructor(authorizeUrl: string, message?: string);
9
+ }
10
+ /**
11
+ * Headless predicate. `HAKIRA_TOKEN` is handled earlier (resolveToken short-
12
+ * circuits on it), so this checks only the no-browser conditions.
13
+ */
14
+ export declare function isHeadless(env?: NodeJS.ProcessEnv): boolean;
15
+ /** Spawn the OS browser opener. Returns false (never throws) if the spawn fails. */
16
+ export declare function openBrowser(url: string): boolean;
17
+ export interface LoginOptions {
18
+ env?: NodeJS.ProcessEnv;
19
+ openBrowser?: (url: string) => boolean;
20
+ }
21
+ /**
22
+ * Run the full browser-login flow and return a fresh `hk_pat_…`.
23
+ * Throws `AuthorizationRequiredError` (never hangs) when the machine is headless
24
+ * or the browser could not be opened.
25
+ */
26
+ export declare function login(opts?: LoginOptions): Promise<string>;
@@ -0,0 +1,178 @@
1
+ import http from 'node:http';
2
+ import { spawn } from 'node:child_process';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import { config } from '../config.js';
5
+ import { log } from '../log.js';
6
+ // Browser-login: RFC 8252 native-app loopback + PKCE (client half of spec §6.1).
7
+ // The CP routes GET /mcp/authorize + POST /mcp/token are ticket 03 (00 §3 rows 4–5).
8
+ const LOGIN_TIMEOUT_MS = 5 * 60_000;
9
+ /**
10
+ * The client could not open a browser (SSH / CI / headless / spawn failed).
11
+ * Surfaced upward as a structured MCP result, NEVER a hang (00 §7, gotcha G8).
12
+ */
13
+ export class AuthorizationRequiredError extends Error {
14
+ authorize_url;
15
+ code = 'authorization_required';
16
+ constructor(authorizeUrl, message) {
17
+ super(message ??
18
+ 'Visit this URL to authorize Hakira on this machine (or set HAKIRA_TOKEN for headless / CI).');
19
+ this.name = 'AuthorizationRequiredError';
20
+ this.authorize_url = authorizeUrl;
21
+ }
22
+ }
23
+ /**
24
+ * Headless predicate. `HAKIRA_TOKEN` is handled earlier (resolveToken short-
25
+ * circuits on it), so this checks only the no-browser conditions.
26
+ */
27
+ export function isHeadless(env = process.env) {
28
+ if (env.SSH_CONNECTION)
29
+ return true;
30
+ if (env.CI)
31
+ return true;
32
+ if (process.platform === 'linux' && !env.DISPLAY)
33
+ return true;
34
+ return false;
35
+ }
36
+ /** Spawn the OS browser opener. Returns false (never throws) if the spawn fails. */
37
+ export function openBrowser(url) {
38
+ try {
39
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
40
+ const child = spawn(cmd, [url], {
41
+ detached: true,
42
+ stdio: 'ignore',
43
+ shell: process.platform === 'win32', // `start` is a shell builtin
44
+ });
45
+ child.on('error', () => { }); // async spawn errors must not crash the process
46
+ child.unref();
47
+ return true;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ function startLoopback(expectedState) {
54
+ return new Promise((resolveStart) => {
55
+ let resolveCode;
56
+ let rejectCode;
57
+ const codePromise = new Promise((res, rej) => {
58
+ resolveCode = res;
59
+ rejectCode = rej;
60
+ });
61
+ const server = http.createServer((req, res) => {
62
+ const reqUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
63
+ if (reqUrl.pathname !== '/callback') {
64
+ res.writeHead(404);
65
+ res.end();
66
+ return;
67
+ }
68
+ const code = reqUrl.searchParams.get('code');
69
+ const state = reqUrl.searchParams.get('state');
70
+ if (!code || state !== expectedState) {
71
+ res.writeHead(400, { 'content-type': 'text/html' });
72
+ res.end('<h1>Authorization failed</h1><p>Invalid state — please retry.</p>');
73
+ rejectCode(new Error('state mismatch (possible CSRF) — aborting login'));
74
+ return;
75
+ }
76
+ res.writeHead(200, { 'content-type': 'text/html' });
77
+ res.end(SUCCESS_HTML);
78
+ resolveCode(code);
79
+ });
80
+ server.listen(0, '127.0.0.1', () => {
81
+ const addr = server.address();
82
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
83
+ resolveStart({
84
+ port,
85
+ close: () => server.close(),
86
+ waitForCode: () => new Promise((resolve, reject) => {
87
+ const timer = setTimeout(() => {
88
+ reject(new AuthorizationRequiredError(buildAuthorizeUrl({ code_challenge: '', redirect_uri: '', state: '' }), 'Login timed out after 5 minutes. Re-run the tool to try again, or set HAKIRA_TOKEN.'));
89
+ }, LOGIN_TIMEOUT_MS);
90
+ codePromise.then((c) => {
91
+ clearTimeout(timer);
92
+ resolve(c);
93
+ }, (e) => {
94
+ clearTimeout(timer);
95
+ reject(e);
96
+ });
97
+ }),
98
+ });
99
+ });
100
+ });
101
+ }
102
+ function buildAuthorizeUrl(p) {
103
+ // `/mcp/authorize` is a CP route (mcp-auth-routes.ts), reached under the `/api`
104
+ // base — NOT a web-app SPA page. Build it from apiUrl, same as /mcp/token below.
105
+ const u = new URL(`${config.apiUrl}/mcp/authorize`);
106
+ u.searchParams.set('response_type', 'code');
107
+ if (p.code_challenge)
108
+ u.searchParams.set('code_challenge', p.code_challenge);
109
+ u.searchParams.set('code_challenge_method', 'S256');
110
+ if (p.redirect_uri)
111
+ u.searchParams.set('redirect_uri', p.redirect_uri);
112
+ if (p.state)
113
+ u.searchParams.set('state', p.state);
114
+ return u.toString();
115
+ }
116
+ /**
117
+ * Run the full browser-login flow and return a fresh `hk_pat_…`.
118
+ * Throws `AuthorizationRequiredError` (never hangs) when the machine is headless
119
+ * or the browser could not be opened.
120
+ */
121
+ export async function login(opts = {}) {
122
+ const env = opts.env ?? process.env;
123
+ const open = opts.openBrowser ?? openBrowser;
124
+ // 1. PKCE
125
+ const codeVerifier = randomBytes(32).toString('base64url');
126
+ const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
127
+ const state = randomBytes(16).toString('base64url');
128
+ // Environment-detected headless → no loopback, no browser. Surface a base
129
+ // authorize URL the human can open on a browser-capable machine.
130
+ if (isHeadless(env)) {
131
+ throw new AuthorizationRequiredError(buildAuthorizeUrl({ code_challenge: codeChallenge, redirect_uri: '', state }));
132
+ }
133
+ // 2. loopback listener (OS picks the port)
134
+ const loopback = await startLoopback(state);
135
+ const redirectUri = `http://127.0.0.1:${loopback.port}/callback`;
136
+ const authorizeUrl = buildAuthorizeUrl({
137
+ code_challenge: codeChallenge,
138
+ redirect_uri: redirectUri,
139
+ state,
140
+ });
141
+ try {
142
+ // 3. open browser; a spawn failure is treated as headless
143
+ const opened = open(authorizeUrl);
144
+ if (!opened) {
145
+ throw new AuthorizationRequiredError(authorizeUrl);
146
+ }
147
+ log.info('Opened browser for authorization — waiting for approval…');
148
+ // 4. receive code on loopback (state validated inside the handler)
149
+ const code = await loopback.waitForCode();
150
+ // 5. exchange at CP (row 5) — NO bearer, public PKCE
151
+ const res = await fetch(`${config.apiUrl}/mcp/token`, {
152
+ method: 'POST',
153
+ headers: { 'content-type': 'application/json' },
154
+ body: JSON.stringify({
155
+ grant_type: 'authorization_code',
156
+ code,
157
+ code_verifier: codeVerifier,
158
+ redirect_uri: redirectUri,
159
+ }),
160
+ });
161
+ if (!res.ok) {
162
+ const detail = await res.text().catch(() => '');
163
+ throw new Error(`Token exchange failed (${res.status}): ${detail || 'invalid_grant'}`);
164
+ }
165
+ const json = (await res.json());
166
+ if (!json.access_token)
167
+ throw new Error('Token exchange returned no access_token');
168
+ log.info('Authorization complete.');
169
+ return json.access_token;
170
+ }
171
+ finally {
172
+ loopback.close();
173
+ }
174
+ }
175
+ const SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Hakira</title>
176
+ <style>body{font-family:system-ui,-apple-system,sans-serif;display:grid;place-items:center;height:100vh;margin:0;background:#0b0b0f;color:#e8e8ec}
177
+ .card{text-align:center;padding:2rem 3rem}h1{font-size:1.3rem;margin:0 0 .5rem}p{opacity:.7}</style></head>
178
+ <body><div class="card"><h1>Authorization complete</h1><p>You can close this tab and return to your terminal.</p></div></body></html>`;
@@ -0,0 +1,8 @@
1
+ export declare const config: {
2
+ /** Control-Plane HTTP base (the §3 endpoint table + /mcp/authorize + /mcp/token). */
3
+ readonly apiUrl: string;
4
+ /** App host for human-facing SPA pages (e.g. `/billing` top-up). NOT CP routes. */
5
+ readonly webUrl: string;
6
+ /** Fallback PAT (00 §2 path B — fully headless, no browser, no cache). */
7
+ readonly envToken: string | undefined;
8
+ };
package/dist/config.js ADDED
@@ -0,0 +1,21 @@
1
+ // Runtime configuration for hakira-mcp. Pure + env-driven (spec §7 "Configurable
2
+ // endpoints"). Read once at import; overridable in every deployment via env.
3
+ //
4
+ // Prod topology (confirmed 2026-09-22, ticket 06 R1 resolved): Netlify serves the
5
+ // web-app at `app.hakira.io` and rewrites `/api/*` → the Control-Plane Railway
6
+ // service (`netlify.toml`, `/api` stripped). There is NO `api.hakira.io` — the CP
7
+ // has no custom domain of its own. So:
8
+ // • apiUrl = `https://app.hakira.io/api` — CP API base. ALL CP routes live here,
9
+ // including the browser-login `/mcp/authorize` + `/mcp/token` (they are Hono
10
+ // routes on the CP, not web-app pages).
11
+ // • webUrl = `https://app.hakira.io` — the human app host. Only SPA pages live
12
+ // here (e.g. `/billing`, the Polar top-up page). NOT for CP routes.
13
+ // e2e always overrides via HAKIRA_API_URL / HAKIRA_WEB_URL.
14
+ export const config = {
15
+ /** Control-Plane HTTP base (the §3 endpoint table + /mcp/authorize + /mcp/token). */
16
+ apiUrl: (process.env.HAKIRA_API_URL ?? 'https://app.hakira.io/api').replace(/\/+$/, ''),
17
+ /** App host for human-facing SPA pages (e.g. `/billing` top-up). NOT CP routes. */
18
+ webUrl: (process.env.HAKIRA_WEB_URL ?? 'https://app.hakira.io').replace(/\/+$/, ''),
19
+ /** Fallback PAT (00 §2 path B — fully headless, no browser, no cache). */
20
+ envToken: process.env.HAKIRA_TOKEN,
21
+ };
@@ -0,0 +1,23 @@
1
+ /** A caller-supplied revision that must never reach git argv (F8). */
2
+ export declare class InvalidGitRefError extends Error {
3
+ constructor(ref: string, reason: string);
4
+ }
5
+ /**
6
+ * Gate for every caller-supplied revision. `ref` reaches us from the coding
7
+ * agent's tool call — i.e. from an LLM that may be summarizing untrusted repo
8
+ * content — so a leading `-` (or whitespace/control bytes, which git refs cannot
9
+ * contain anyway) is refused loudly rather than handed to git. Belt-and-braces
10
+ * with the `--end-of-options` separator that `runGit` inserts.
11
+ */
12
+ export declare function assertSafeRef(ref: string): string;
13
+ /**
14
+ * Run `git <args>` in `root`, return trimmed stdout, or `null` on any failure
15
+ * (non-git folder, missing git, non-zero exit). stderr is suppressed.
16
+ *
17
+ * An invalid `userRef` THROWS (it is a caller bug, not a git failure) — the tool
18
+ * layer surfaces it as a clean client-side error.
19
+ */
20
+ export declare function tryGit(root: string, args: string[], userRef?: string): string | null;
21
+ /** Raw (unparsed) `git` invocation returning a Buffer — for `git archive`. */
22
+ export declare function gitBuffer(root: string, args: string[], userRef?: string): Buffer;
23
+ export declare function isGitRepo(root: string): boolean;
@@ -0,0 +1,75 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ /**
3
+ * git's own end-of-options marker. Everything after it is a revision/path, never
4
+ * an option — so a `--output=…`-shaped revision is rejected by git as a bad
5
+ * object name instead of being honoured as a flag.
6
+ */
7
+ const END_OF_OPTIONS = '--end-of-options';
8
+ /** A caller-supplied revision that must never reach git argv (F8). */
9
+ export class InvalidGitRefError extends Error {
10
+ constructor(ref, reason) {
11
+ super(`Invalid git ref ${JSON.stringify(ref)}: ${reason}.`);
12
+ this.name = 'InvalidGitRefError';
13
+ }
14
+ }
15
+ /**
16
+ * Gate for every caller-supplied revision. `ref` reaches us from the coding
17
+ * agent's tool call — i.e. from an LLM that may be summarizing untrusted repo
18
+ * content — so a leading `-` (or whitespace/control bytes, which git refs cannot
19
+ * contain anyway) is refused loudly rather than handed to git. Belt-and-braces
20
+ * with the `--end-of-options` separator that `runGit` inserts.
21
+ */
22
+ export function assertSafeRef(ref) {
23
+ if (ref.length === 0)
24
+ throw new InvalidGitRefError(ref, 'it is empty');
25
+ if (ref.startsWith('-')) {
26
+ throw new InvalidGitRefError(ref, 'a ref may not start with "-" (git would read it as an option)');
27
+ }
28
+ if (/[\s\u0000-\u001f\u007f]/.test(ref)) {
29
+ throw new InvalidGitRefError(ref, 'it contains whitespace or control characters');
30
+ }
31
+ return ref;
32
+ }
33
+ /**
34
+ * Build the argv for one git call. `fixedArgs` are literals owned by this
35
+ * package; `userRef` — the ONE channel through which caller data may reach git —
36
+ * is validated and placed after `--end-of-options`, always last.
37
+ */
38
+ function buildArgv(fixedArgs, userRef) {
39
+ if (fixedArgs.includes(END_OF_OPTIONS)) {
40
+ throw new Error('pass a caller-supplied revision via the userRef argument, not in fixedArgs');
41
+ }
42
+ return userRef === undefined ? fixedArgs : [...fixedArgs, END_OF_OPTIONS, assertSafeRef(userRef)];
43
+ }
44
+ /**
45
+ * Run `git <args>` in `root`, return trimmed stdout, or `null` on any failure
46
+ * (non-git folder, missing git, non-zero exit). stderr is suppressed.
47
+ *
48
+ * An invalid `userRef` THROWS (it is a caller bug, not a git failure) — the tool
49
+ * layer surfaces it as a clean client-side error.
50
+ */
51
+ export function tryGit(root, args, userRef) {
52
+ const argv = buildArgv(args, userRef);
53
+ try {
54
+ return execFileSync('git', argv, {
55
+ cwd: root,
56
+ encoding: 'utf8',
57
+ stdio: ['ignore', 'pipe', 'ignore'],
58
+ maxBuffer: 64 * 1024 * 1024,
59
+ }).trim();
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /** Raw (unparsed) `git` invocation returning a Buffer — for `git archive`. */
66
+ export function gitBuffer(root, args, userRef) {
67
+ return execFileSync('git', buildArgv(args, userRef), {
68
+ cwd: root,
69
+ stdio: ['ignore', 'pipe', 'ignore'],
70
+ maxBuffer: 1024 * 1024 * 1024,
71
+ });
72
+ }
73
+ export function isGitRepo(root) {
74
+ return tryGit(root, ['rev-parse', '--is-inside-work-tree']) === 'true';
75
+ }
@@ -0,0 +1,20 @@
1
+ export interface GitMetadata {
2
+ commit_sha: string | null;
3
+ ref: string | null;
4
+ is_dirty: boolean;
5
+ remote_url: string | null;
6
+ }
7
+ /** The user's REAL git state — `commit_sha` is HEAD (or the resolved `ref`). */
8
+ export declare function captureGitMetadata(root: string, ref?: string): GitMetadata;
9
+ /**
10
+ * Changed-file list for `scope:'diff'`. A WORKING-TREE diff vs the merge-base
11
+ * with the default branch — so it naturally includes uncommitted edits to
12
+ * tracked files (the point; sidesteps Semgrep's dirty-tree limit, spec §9).
13
+ * Shipped as a prioritization HINT, not a hard allowlist (00 §3 row 8).
14
+ */
15
+ export declare function computeChangedFiles(root: string, opts?: {
16
+ ref?: string;
17
+ base?: string;
18
+ }): string[];
19
+ /** Human summary for sessions.target_gist (00 §5). */
20
+ export declare function buildTargetGist(meta: GitMetadata, scope: 'full' | 'diff', changedFiles: string[]): string;