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
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Upload the ZIP and return the R2 key `start_audit` passes to POST /mcp/audits.
3
+ *
4
+ * On `firstBind` this uploads TWICE (00 §3 note / G5): once to drive the one-shot
5
+ * `/bootstrap` (consumed + deleted after AR extracts), then again for the scan's
6
+ * re-sync — the returned key is always a FRESH, unconsumed object.
7
+ *
8
+ * NON-BLOCKING (Option B): we do NOT wait for the first-bind bootstrap to finish.
9
+ * `completeUpload` enqueues it and returns; `start_audit` then POSTs to CP, which
10
+ * ACCEPTS the audit while the workspace is still provisioning and drives it via
11
+ * the deferred-kickoff worker once bootstrap completes. Honoring `start_audit`'s
12
+ * "returns immediately" promise is the whole point — the agent polls
13
+ * `get_audit_status` through provisioning → running → ready instead of the tool
14
+ * call blocking for the multi-minute provision window.
15
+ */
16
+ export async function upload(cp, wsId, buffer, opts) {
17
+ if (opts.firstBind) {
18
+ const boot = await cp.getUploadUrl(wsId, { size: buffer.length, contentType: 'application/zip' });
19
+ await cp.putUpload(boot.uploadUrl, buffer);
20
+ await cp.completeUpload(wsId, {
21
+ key: boot.key,
22
+ filename: boot.filename,
23
+ excludeFolders: opts.excludeFolders ?? [],
24
+ });
25
+ // Deliberately no bootstrap wait — see the docstring (Option B).
26
+ }
27
+ // Fresh upload for the scan's re-sync (never reuse the bootstrap object). The
28
+ // object persists in R2 until the deferred drive consumes it via /re-sync.
29
+ const scan = await cp.getUploadUrl(wsId, { size: buffer.length, contentType: 'application/zip' });
30
+ await cp.putUpload(scan.uploadUrl, buffer);
31
+ return { r2_key: scan.key };
32
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Default secret deny-list applied ON TOP of .gitignore. One exported const so
3
+ * tests + docs share it. Extensible via a `.hakiraignore` at the project root.
4
+ */
5
+ export declare const SECRET_DENY_LIST: readonly [".env*", "*.pem", "*.key", "id_rsa*", "id_ed25519*", "id_ecdsa*", "id_dsa*", ".git-credentials", "*.p12", "*.pfx", "*.pkcs12", "*.jks", "*.p8", "service-account*.json", ".ssh/", ".aws/", ".npmrc", "*.keystore"];
6
+ export interface ZipResult {
7
+ buffer: Buffer;
8
+ manifest: string;
9
+ files: string[];
10
+ excluded: string[];
11
+ }
12
+ export interface BuildZipOptions {
13
+ ref?: string;
14
+ extraExcludes?: string[];
15
+ }
16
+ export declare function buildZip(root: string, opts?: BuildZipOptions): ZipResult;
17
+ /**
18
+ * Match a POSIX relative path against a deny-list pattern set.
19
+ * - `foo/` (trailing slash) → directory: any path SEGMENT equals `foo`.
20
+ * - else → basename glob (`*` = any run of chars).
21
+ */
22
+ export declare function isDenied(relPath: string, patterns: string[]): boolean;
@@ -0,0 +1,182 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { join, relative, sep } from 'node:path';
3
+ import AdmZip from 'adm-zip';
4
+ import { tryGit, gitBuffer, isGitRepo } from '../git/exec.js';
5
+ // gitignore-respecting ZIP + secret deny-list + manifest (ticket S7, spec §9/§9.1).
6
+ // The user's real .git is NEVER shipped (the git-substrate is built server-side).
7
+ /**
8
+ * Default secret deny-list applied ON TOP of .gitignore. One exported const so
9
+ * tests + docs share it. Extensible via a `.hakiraignore` at the project root.
10
+ */
11
+ export const SECRET_DENY_LIST = [
12
+ '.env*',
13
+ '*.pem',
14
+ '*.key',
15
+ 'id_rsa*',
16
+ 'id_ed25519*', // non-RSA SSH private keys at repo root (the `.ssh/` rule only
17
+ 'id_ecdsa*', // catches keys UNDER a .ssh/ dir; bare ones would slip)
18
+ 'id_dsa*',
19
+ '.git-credentials', // git stores creds here in plaintext
20
+ '*.p12',
21
+ '*.pfx',
22
+ '*.pkcs12',
23
+ '*.jks', // Java keystore
24
+ '*.p8', // PKCS#8 / Apple auth keys
25
+ 'service-account*.json', // GCP service-account keys
26
+ '.ssh/',
27
+ '.aws/',
28
+ '.npmrc',
29
+ '*.keystore',
30
+ ];
31
+ export function buildZip(root, opts = {}) {
32
+ const patterns = [...SECRET_DENY_LIST, ...readHakiraIgnore(root), ...(opts.extraExcludes ?? [])];
33
+ if (opts.ref)
34
+ return buildFromRef(root, opts.ref, patterns);
35
+ if (isGitRepo(root))
36
+ return buildFromWorkingTree(root, patterns);
37
+ return buildFromWalk(root, patterns);
38
+ }
39
+ /**
40
+ * Add one filesystem path to the zip unless it is deny-listed or a symlink.
41
+ * Records the path in `kept` (added) or `excluded` (denied / symlink); an
42
+ * unreadable or vanished file is silently skipped. Returns the byte count added.
43
+ *
44
+ * NEVER dereferences a symlink: readFileSync would follow it and ship the
45
+ * TARGET's bytes (e.g. a `link.txt -> ~/.aws/credentials`), which the name-based
46
+ * deny-list cannot see. This lstat + isSymbolicLink gate is the ONE place both
47
+ * the git-working-tree and the non-git-walk builders route files through, so the
48
+ * symlink-safety logic lives in a single spot.
49
+ */
50
+ function addFileIfSafe(zip, abs, rel, patterns, kept, excluded) {
51
+ if (isDenied(rel, patterns)) {
52
+ excluded.push(rel);
53
+ return 0;
54
+ }
55
+ try {
56
+ if (lstatSync(abs).isSymbolicLink()) {
57
+ excluded.push(rel);
58
+ return 0;
59
+ }
60
+ const buf = readFileSync(abs);
61
+ zip.addFile(rel, buf);
62
+ kept.push(rel);
63
+ return buf.length;
64
+ }
65
+ catch {
66
+ return 0; // unreadable / deleted-since-listing
67
+ }
68
+ }
69
+ // ── git working tree (default): honors .gitignore, includes untracked-but-un-
70
+ // ignored files (the uncommitted files we DO scan) — G12 incantation. ──────
71
+ function buildFromWorkingTree(root, patterns) {
72
+ const out = tryGit(root, ['ls-files', '--cached', '--others', '--exclude-standard']);
73
+ const files = out ? out.split('\n').filter(Boolean) : [];
74
+ const zip = new AdmZip();
75
+ const excluded = [];
76
+ const kept = [];
77
+ let bytes = 0;
78
+ for (const rel of files) {
79
+ if (isGitPath(rel))
80
+ continue;
81
+ bytes += addFileIfSafe(zip, join(root, rel), rel, patterns, kept, excluded);
82
+ }
83
+ return finalize(zip, kept, bytes, excluded);
84
+ }
85
+ // ── explicit ref: `git archive` reads blob contents AT that ref (correct even
86
+ // if the working tree differs). Deny-list applied on top. ─────────────────
87
+ function buildFromRef(root, ref, patterns) {
88
+ const srcBuf = gitBuffer(root, ['archive', '--format=zip'], ref);
89
+ const src = new AdmZip(srcBuf);
90
+ const out = new AdmZip();
91
+ const excluded = [];
92
+ const kept = [];
93
+ let bytes = 0;
94
+ for (const e of src.getEntries()) {
95
+ if (e.isDirectory)
96
+ continue;
97
+ const rel = e.entryName;
98
+ if (isGitPath(rel))
99
+ continue;
100
+ if (isDenied(rel, patterns)) {
101
+ excluded.push(rel);
102
+ continue;
103
+ }
104
+ const data = e.getData();
105
+ out.addFile(rel, data);
106
+ bytes += data.length;
107
+ kept.push(rel);
108
+ }
109
+ return finalize(out, kept, bytes, excluded);
110
+ }
111
+ // ── non-git folder: recursive walk, skipping .git / node_modules + deny-list. ─
112
+ function buildFromWalk(root, patterns) {
113
+ const zip = new AdmZip();
114
+ const excluded = [];
115
+ const kept = [];
116
+ let bytes = 0;
117
+ const walk = (dir) => {
118
+ for (const name of readdirSync(dir)) {
119
+ if (name === '.git' || name === 'node_modules')
120
+ continue;
121
+ const abs = join(dir, name);
122
+ const rel = relative(root, abs).split(sep).join('/');
123
+ // lstat, not stat: a symlink must NOT be followed. isDirectory() is false
124
+ // for a symlink under lstat, so a dir-symlink is never walked (can't escape
125
+ // the tree or loop); everything else routes through the shared guard, which
126
+ // excludes file-symlinks rather than shipping their target's bytes.
127
+ if (lstatSync(abs).isDirectory()) {
128
+ walk(abs);
129
+ continue;
130
+ }
131
+ bytes += addFileIfSafe(zip, abs, rel, patterns, kept, excluded);
132
+ }
133
+ };
134
+ walk(root);
135
+ return finalize(zip, kept, bytes, excluded);
136
+ }
137
+ function finalize(zip, kept, bytes, excluded) {
138
+ const mb = (bytes / 1_048_576).toFixed(2);
139
+ const manifest = `uploading ${kept.length} files / ${mb} MB` +
140
+ (excluded.length ? `; excluded (secret/ignore): ${excluded.join(', ')}` : '; excluded: none');
141
+ return { buffer: zip.toBuffer(), manifest, files: kept, excluded };
142
+ }
143
+ function isGitPath(rel) {
144
+ return rel === '.git' || rel.startsWith('.git/');
145
+ }
146
+ function readHakiraIgnore(root) {
147
+ const file = join(root, '.hakiraignore');
148
+ if (!existsSync(file))
149
+ return [];
150
+ try {
151
+ return readFileSync(file, 'utf8')
152
+ .split('\n')
153
+ .map((l) => l.trim())
154
+ .filter((l) => l.length > 0 && !l.startsWith('#'));
155
+ }
156
+ catch {
157
+ return [];
158
+ }
159
+ }
160
+ function globToRegExp(glob) {
161
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
162
+ return new RegExp('^' + escaped + '$');
163
+ }
164
+ /**
165
+ * Match a POSIX relative path against a deny-list pattern set.
166
+ * - `foo/` (trailing slash) → directory: any path SEGMENT equals `foo`.
167
+ * - else → basename glob (`*` = any run of chars).
168
+ */
169
+ export function isDenied(relPath, patterns) {
170
+ const parts = relPath.split('/');
171
+ const base = parts[parts.length - 1];
172
+ for (const p of patterns) {
173
+ if (p.endsWith('/')) {
174
+ if (parts.includes(p.slice(0, -1)))
175
+ return true;
176
+ }
177
+ else if (globToRegExp(p).test(base)) {
178
+ return true;
179
+ }
180
+ }
181
+ return false;
182
+ }
@@ -0,0 +1 @@
1
+ export declare const version: string;
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+ // Read the package version at runtime from the installed package.json (one level
5
+ // up from dist/). Avoids a JSON import that tsc would try to place outside rootDir.
6
+ function readVersion() {
7
+ try {
8
+ const here = dirname(fileURLToPath(import.meta.url)); // dist/
9
+ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
10
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
11
+ }
12
+ catch {
13
+ return '0.0.0';
14
+ }
15
+ }
16
+ export const version = readVersion();
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "hakira-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Hakira MCP server — trigger Hakira cloud security audits from a coding agent",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Hakira",
8
+ "homepage": "https://hakira.io",
9
+ "keywords": [
10
+ "mcp",
11
+ "model-context-protocol",
12
+ "security",
13
+ "security-audit",
14
+ "sast",
15
+ "pentest",
16
+ "claude",
17
+ "ai",
18
+ "hakira"
19
+ ],
20
+ "bin": { "hakira-mcp": "dist/index.js" },
21
+ "files": ["dist", "README.md", "LICENSE"],
22
+ "engines": { "node": ">=18" },
23
+ "publishConfig": { "access": "public" },
24
+ "scripts": {
25
+ "build": "rm -rf dist && tsc && chmod +x dist/index.js",
26
+ "dev": "tsx src/index.ts",
27
+ "test": "node --import tsx --test tests/*.test.ts",
28
+ "prepublishOnly": "npm run build"
29
+ },
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.29.0",
32
+ "adm-zip": "^0.5.17",
33
+ "zod": "^4.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/adm-zip": "^0.5.8",
37
+ "@types/node": "^20.0.0",
38
+ "tsx": "^4.0.0",
39
+ "typescript": "^5.0.0"
40
+ }
41
+ }