@morphed/agent-hook 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 peakmojo
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,62 @@
1
+ # @morphed/agent-hook
2
+
3
+ Morphed pre-tool hook for **Claude Code** and **Codex** — Build Guide Mechanism C.
4
+ A tiny program the agent runs before any tool call. It asks Morphed's decision
5
+ service and denies the call if policy says no, **before the write happens** —
6
+ even in bypass/yolo mode (the bypass skips prompts, not hooks).
7
+
8
+ ## Install (one line)
9
+
10
+ ```bash
11
+ npx @morphed/agent-hook init --token <MORPHED_API_KEY> --portal <PORTAL_ID>
12
+ ```
13
+
14
+ This:
15
+ 1. Detects which agents are installed (`~/.claude/settings.json`, `~/.codex/config.toml`).
16
+ 2. Writes the `PreToolUse` hook with matchers `mcp__hubspot__.*|Bash`.
17
+ 3. Stores the API key in the OS keychain (via `keytar`; falls back to a `0600`
18
+ `~/.morphed/agent-hook.json` if the native module isn't available).
19
+
20
+ ### Whole-org enforcement
21
+
22
+ ```bash
23
+ npx @morphed/agent-hook init --token <KEY> --portal <ID> --org
24
+ ```
25
+
26
+ `--org` writes **managed settings** so individuals can't disable it:
27
+ - Claude Code: managed `managed-settings.json` (OS-specific path).
28
+ - Codex: `requirements.toml` with `allow_managed_hooks_only = true`.
29
+
30
+ ## How the hook works
31
+
32
+ `morphed-hook` reads the agent's pre-tool JSON from stdin, pulls `tool_name` +
33
+ `tool_input`, calls `POST /v1/decision` (`surface: claude_code` or `codex`,
34
+ HMAC-signed with the tenant key), and prints the agent's allow/deny JSON:
35
+
36
+ ```json
37
+ { "hookSpecificOutput": { "hookEventName": "PreToolUse",
38
+ "permissionDecision": "deny",
39
+ "permissionDecisionReason": "Morphed blocked this write. … STOP — do not retry…" } }
40
+ ```
41
+
42
+ - Morphed `approved` → `allow`; `blocked`/`approval_required` → `deny`;
43
+ `modify` → `ask`.
44
+ - Deny copy is phrased to make the agent **stop cleanly and escalate** rather
45
+ than go idle or loop on a block.
46
+ - Codex `PermissionRequest` events get the `decision.behavior` shape; both agents'
47
+ `PreToolUse` events get the `hookSpecificOutput.permissionDecision` shape.
48
+ - **Fail-open**: if Morphed is unreachable or the machine isn't enrolled, the
49
+ hook allows the call (the backstop, Mechanism F, still catches the write) — a
50
+ Morphed outage never wedges the agent.
51
+
52
+ ## Honest limit
53
+
54
+ The hook governs the machines it's installed on. It is **enrolment, not magic** —
55
+ Morphed can't gate an agent it was never wired into. Anything outside it still
56
+ falls to the backstop.
57
+
58
+ ## Tests
59
+
60
+ ```bash
61
+ npm test # 16 unit tests: both agent JSON shapes, decision mapping, HMAC, installer
62
+ ```
package/bin/cli.mjs ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agent-hook CLI — `npx @morphed/agent-hook init`.
4
+ *
5
+ * Flags:
6
+ * --token <key> Morphed API key (the per-tenant signing secret). Or MORPHED_API_KEY.
7
+ * --portal <id> Morphed portal id. Or MORPHED_PORTAL_ID.
8
+ * --api-base <url> default https://api.morphed.io. Or MORPHED_API_BASE.
9
+ * --org write managed settings so individuals can't disable it.
10
+ * --agent <name> force a target (claude_code|codex); repeatable. Default: auto-detect.
11
+ */
12
+
13
+ import installer from '../src/installer.mjs';
14
+
15
+ function parseArgs(argv) {
16
+ const out = { agents: [] };
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const a = argv[i];
19
+ if (a === 'init') out.cmd = 'init';
20
+ else if (a === '--org') out.org = true;
21
+ else if (a === '--token') out.token = argv[++i];
22
+ else if (a === '--portal') out.portalId = argv[++i];
23
+ else if (a === '--api-base') out.apiBase = argv[++i];
24
+ else if (a === '--agent') out.agents.push(argv[++i]);
25
+ }
26
+ return out;
27
+ }
28
+
29
+ async function main() {
30
+ const args = parseArgs(process.argv.slice(2));
31
+ if (args.cmd !== 'init') {
32
+ console.log('Usage: npx @morphed/agent-hook init --token <key> --portal <id> [--org] [--agent claude_code|codex]');
33
+ process.exit(args.cmd ? 0 : 1);
34
+ }
35
+ const token = args.token || process.env.MORPHED_API_KEY;
36
+ const portalId = args.portalId || process.env.MORPHED_PORTAL_ID;
37
+ const apiBase = args.apiBase || process.env.MORPHED_API_BASE || 'https://api.morphed.io';
38
+ const agents = args.agents.length ? args.agents : null;
39
+
40
+ const result = await installer.init({ token, portalId, apiBase, org: !!args.org, agents });
41
+
42
+ console.log(`✅ Morphed agent hook installed.`);
43
+ console.log(` API key stored via: ${result.secretStore}`);
44
+ console.log(` Agents: ${result.agents.join(', ') || '(none detected — pass --agent)'}`);
45
+ for (const w of result.written) {
46
+ console.log(` • ${w.agent}: ${w.managed ? 'managed ' : ''}${w.file}`);
47
+ }
48
+ if (result.org) console.log(` Org-managed: individuals cannot disable this hook.`);
49
+ console.log(` Matcher: ${installer.HOOK_MATCHER}`);
50
+ }
51
+
52
+ main().catch((err) => {
53
+ console.error(`✗ ${err.message}`);
54
+ process.exit(1);
55
+ });
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * morphed-hook — the pre-tool hook binary (Mechanism C).
4
+ *
5
+ * Reads the agent's pre-tool JSON from stdin, extracts tool_name + tool_input,
6
+ * calls /v1/decision (surface=claude_code or codex), and prints the agent's
7
+ * allow/deny JSON to stdout. Exits 0 in all cases (the JSON carries the verdict;
8
+ * exit 2 is reserved as a hard-deny fallback if stdout can't be written).
9
+ */
10
+
11
+ import { loadCredentials } from '../src/keychain.mjs';
12
+ import { requestDecision } from '../src/decisionClient.mjs';
13
+ import { buildHookOutput, toDecisionInput } from '../src/shapes.mjs';
14
+
15
+ function readStdin() {
16
+ return new Promise((resolve) => {
17
+ let data = '';
18
+ process.stdin.setEncoding('utf8');
19
+ process.stdin.on('data', (c) => { data += c; });
20
+ process.stdin.on('end', () => resolve(data));
21
+ // If nothing is piped, don't hang forever.
22
+ setTimeout(() => resolve(data), 2000);
23
+ });
24
+ }
25
+
26
+ function detectSurface(payload) {
27
+ // turn_id is Codex-specific; CODEX_* env or an explicit surface override also work.
28
+ if (process.env.MORPHED_HOOK_SURFACE) return process.env.MORPHED_HOOK_SURFACE;
29
+ if (payload.turn_id || process.env.CODEX_HOME || /codex/i.test(payload.agent || '')) return 'codex';
30
+ return 'claude_code';
31
+ }
32
+
33
+ async function main() {
34
+ const raw = await readStdin();
35
+ let payload = {};
36
+ try { payload = raw ? JSON.parse(raw) : {}; } catch { payload = {}; }
37
+
38
+ const surface = detectSurface(payload);
39
+ const hookEventName = payload.hook_event_name || 'PreToolUse';
40
+
41
+ const creds = await loadCredentials();
42
+ if (!creds.secret || !creds.portalId) {
43
+ // Not configured → defer to the agent's normal flow (allow) rather than block
44
+ // a machine that was never enrolled. (Enrolment, not magic — per the brief.)
45
+ process.stdout.write(JSON.stringify(buildHookOutput(surface, { decision: 'approved', reason: 'Morphed hook not configured on this machine.' }, hookEventName)));
46
+ return;
47
+ }
48
+
49
+ const input = toDecisionInput(payload, surface);
50
+ const decision = await requestDecision({ apiBase: creds.apiBase, portalId: creds.portalId, secret: creds.secret, input });
51
+ process.stdout.write(JSON.stringify(buildHookOutput(surface, decision, hookEventName)));
52
+ }
53
+
54
+ main().catch((err) => {
55
+ // Fail-open on unexpected error so a hook bug never wedges the agent.
56
+ process.stderr.write(`[morphed-hook] ${err.message}\n`);
57
+ process.stdout.write(JSON.stringify({
58
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Morphed hook error; allowed (backstop still watching).' },
59
+ }));
60
+ });
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@morphed/agent-hook",
3
+ "version": "0.1.0",
4
+ "description": "Morphed pre-tool hook for Claude Code and Codex — gates HubSpot writes through Morphed's decision service before they happen (Build Guide Mechanism C).",
5
+ "type": "module",
6
+ "bin": {
7
+ "morphed-hook": "bin/morphed-hook.mjs",
8
+ "agent-hook": "bin/cli.mjs"
9
+ },
10
+ "exports": {
11
+ "./shapes": "./src/shapes.mjs",
12
+ "./installer": "./src/installer.mjs",
13
+ "./decisionClient": "./src/decisionClient.mjs"
14
+ },
15
+ "scripts": {
16
+ "test": "node --test \"test/**/*.test.mjs\"",
17
+ "prepublishOnly": "npm test"
18
+ },
19
+ "engines": { "node": ">=18" },
20
+ "optionalDependencies": {
21
+ "keytar": "^7.9.0"
22
+ },
23
+ "files": ["bin/", "src/", "README.md", "LICENSE"],
24
+ "keywords": [
25
+ "claude-code",
26
+ "codex",
27
+ "hook",
28
+ "pre-tool",
29
+ "pretooluse",
30
+ "hubspot",
31
+ "crm",
32
+ "governance",
33
+ "guardrails",
34
+ "agent",
35
+ "morphed"
36
+ ],
37
+ "homepage": "https://github.com/graemewil/Morphed_Magic_Cards_Links_V2/tree/main/packages/agent-hook#readme",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/graemewil/Morphed_Magic_Cards_Links_V2.git",
41
+ "directory": "packages/agent-hook"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/graemewil/Morphed_Magic_Cards_Links_V2/issues"
45
+ },
46
+ "license": "MIT",
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * decisionClient — Mechanism C. Signs the request body with the tenant secret
3
+ * (HMAC, same scheme as the server's decisionSigning) and calls /v1/decision.
4
+ */
5
+
6
+ import crypto from 'node:crypto';
7
+
8
+ export function computeSignature(secret, rawBody) {
9
+ const hex = crypto.createHmac('sha256', String(secret)).update(rawBody).digest('hex');
10
+ return `sha256=${hex}`;
11
+ }
12
+
13
+ /**
14
+ * @returns the parsed /v1/decision response, or a fail-open 'approved' result
15
+ * when Morphed is unreachable (a Morphed outage must not wedge the dev's agent;
16
+ * the backstop still catches the write).
17
+ */
18
+ export async function requestDecision({ apiBase, portalId, secret, input, timeoutMs = 8000, fetchImpl = globalThis.fetch }) {
19
+ const rawBody = JSON.stringify(input);
20
+ const signature = computeSignature(secret, rawBody);
21
+ const controller = new AbortController();
22
+ const t = setTimeout(() => controller.abort(), timeoutMs);
23
+ try {
24
+ const resp = await fetchImpl(`${apiBase.replace(/\/+$/, '')}/v1/decision`, {
25
+ method: 'POST',
26
+ headers: {
27
+ 'Content-Type': 'application/json',
28
+ 'X-Morphed-Portal': String(portalId),
29
+ 'X-Morphed-Signature': signature,
30
+ },
31
+ body: rawBody,
32
+ signal: controller.signal,
33
+ });
34
+ if (!resp.ok) {
35
+ return { decision: 'approved', risk_level: 'low', reason: `Morphed check skipped (HTTP ${resp.status}); backstop still watching.`, _failOpen: true };
36
+ }
37
+ return await resp.json();
38
+ } catch (err) {
39
+ return { decision: 'approved', risk_level: 'low', reason: `Morphed unreachable (${err.message}); backstop still watching.`, _failOpen: true };
40
+ } finally {
41
+ clearTimeout(t);
42
+ }
43
+ }
44
+
45
+ export default { computeSignature, requestDecision };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * installer — `npx @morphed/agent-hook init`.
3
+ *
4
+ * Detects which agents are installed, writes the PreToolUse hook config
5
+ * (matchers mcp__hubspot__.*|Bash), and stores the API key in the OS keychain.
6
+ * With --org it writes managed settings so individuals can't disable it
7
+ * (Claude Code managed-settings.json / Codex requirements.toml).
8
+ *
9
+ * All functions accept a `home` + `platform` for testability.
10
+ */
11
+
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import fs from 'node:fs';
15
+
16
+ export const HOOK_MATCHER = 'mcp__hubspot__.*|Bash';
17
+ const MARKER = 'morphed-agent-hook';
18
+
19
+ function ensureDir(p) { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }
20
+ function readJson(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; } }
21
+ function writeJson(p, obj) { ensureDir(path.dirname(p)); fs.writeFileSync(p, JSON.stringify(obj, null, 2)); }
22
+
23
+ /** Paths for each agent, per scope (user vs org-managed). */
24
+ export function agentPaths({ home = os.homedir(), platform = process.platform } = {}) {
25
+ const claudeUser = path.join(home, '.claude', 'settings.json');
26
+ const codexUser = path.join(home, '.codex', 'config.toml');
27
+ let claudeManaged, codexManaged;
28
+ if (platform === 'win32') {
29
+ claudeManaged = path.join(process.env.ProgramData || 'C:/ProgramData', 'ClaudeCode', 'managed-settings.json');
30
+ codexManaged = path.join(process.env.ProgramData || 'C:/ProgramData', 'Codex', 'requirements.toml');
31
+ } else if (platform === 'darwin') {
32
+ claudeManaged = '/Library/Application Support/ClaudeCode/managed-settings.json';
33
+ codexManaged = '/etc/codex/requirements.toml';
34
+ } else {
35
+ claudeManaged = '/etc/claude-code/managed-settings.json';
36
+ codexManaged = '/etc/codex/requirements.toml';
37
+ }
38
+ return { claudeUser, codexUser, claudeManaged, codexManaged };
39
+ }
40
+
41
+ /** Which agents are installed on this machine. */
42
+ export function detectAgents({ home = os.homedir() } = {}) {
43
+ const found = [];
44
+ if (fs.existsSync(path.join(home, '.claude'))) found.push('claude_code');
45
+ if (fs.existsSync(path.join(home, '.codex'))) found.push('codex');
46
+ return found;
47
+ }
48
+
49
+ /** Insert the Morphed PreToolUse hook into a Claude Code settings.json object. */
50
+ export function applyClaudeHook(settings = {}, { command = 'morphed-hook' } = {}) {
51
+ const next = { ...settings };
52
+ next.hooks = next.hooks || {};
53
+ const list = Array.isArray(next.hooks.PreToolUse) ? next.hooks.PreToolUse.slice() : [];
54
+ // Idempotent: drop any prior morphed entry first.
55
+ const filtered = list.filter((e) => !(e?.hooks || []).some((h) => String(h.command || '').includes('morphed-hook')));
56
+ filtered.push({ matcher: HOOK_MATCHER, hooks: [{ type: 'command', command }] });
57
+ next.hooks.PreToolUse = filtered;
58
+ return next;
59
+ }
60
+
61
+ export function writeClaudeHook({ home = os.homedir(), platform = process.platform, org = false, command = 'morphed-hook' } = {}) {
62
+ const paths = agentPaths({ home, platform });
63
+ const file = org ? paths.claudeManaged : paths.claudeUser;
64
+ const settings = readJson(file);
65
+ const updated = applyClaudeHook(settings, { command });
66
+ writeJson(file, updated);
67
+ return { file, managed: org };
68
+ }
69
+
70
+ /** Build the Codex TOML hook block (+ requirements wrapper for org installs). */
71
+ export function codexHookToml({ org = false, command = 'morphed-hook' } = {}) {
72
+ const block = [
73
+ `# >>> ${MARKER} >>>`,
74
+ ...(org ? ['allow_managed_hooks_only = true', '[features]', 'hooks = true', ''] : []),
75
+ '[[hooks.PreToolUse]]',
76
+ `matcher = "${HOOK_MATCHER}"`,
77
+ '',
78
+ '[[hooks.PreToolUse.hooks]]',
79
+ 'type = "command"',
80
+ `command = "${command}"`,
81
+ 'timeout = 15',
82
+ `# <<< ${MARKER} <<<`,
83
+ '',
84
+ ].join('\n');
85
+ return block;
86
+ }
87
+
88
+ export function writeCodexHook({ home = os.homedir(), platform = process.platform, org = false, command = 'morphed-hook' } = {}) {
89
+ const paths = agentPaths({ home, platform });
90
+ const file = org ? paths.codexManaged : paths.codexUser;
91
+ ensureDir(path.dirname(file));
92
+ let existing = '';
93
+ try { existing = fs.readFileSync(file, 'utf8'); } catch { /* new file */ }
94
+ // Idempotent: strip a prior morphed block.
95
+ const stripped = existing.replace(new RegExp(`# >>> ${MARKER} >>>[\\s\\S]*?# <<< ${MARKER} <<<\\n?`, 'g'), '');
96
+ fs.writeFileSync(file, `${stripped}${stripped && !stripped.endsWith('\n') ? '\n' : ''}${codexHookToml({ org, command })}`);
97
+ return { file, managed: org };
98
+ }
99
+
100
+ /**
101
+ * Full init. Stores credentials + writes hooks for detected (or requested)
102
+ * agents. Returns a summary the CLI prints.
103
+ */
104
+ export async function init({ token, portalId, apiBase = 'https://api.morphed.io', org = false, agents = null, home = os.homedir(), platform = process.platform } = {}) {
105
+ if (!token) throw new Error('init: a Morphed API token is required (--token or MORPHED_API_KEY).');
106
+ if (!portalId) throw new Error('init: a portal id is required (--portal or MORPHED_PORTAL_ID).');
107
+
108
+ const keychain = (await import('./keychain.mjs')).default;
109
+ const stored = await keychain.storeCredentials({ portalId, apiBase, secret: token });
110
+
111
+ const targets = agents || detectAgents({ home });
112
+ const written = [];
113
+ if (targets.includes('claude_code')) written.push({ agent: 'claude_code', ...writeClaudeHook({ home, platform, org }) });
114
+ if (targets.includes('codex')) written.push({ agent: 'codex', ...writeCodexHook({ home, platform, org }) });
115
+
116
+ return { secretStore: stored.secretStore, agents: targets, written, org };
117
+ }
118
+
119
+ export default {
120
+ HOOK_MATCHER, agentPaths, detectAgents, applyClaudeHook, writeClaudeHook,
121
+ codexHookToml, writeCodexHook, init,
122
+ };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * keychain — stores the Morphed API key (the per-tenant signing secret) in the
3
+ * OS keychain via keytar when available, falling back to a 0600 config file so
4
+ * the hook works even where the native keytar module can't be built.
5
+ *
6
+ * Non-secret config (apiBase, portalId) lives in ~/.morphed/agent-hook.json;
7
+ * the secret lives in the keychain (or, in fallback mode, the same file with
8
+ * tight perms).
9
+ */
10
+
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import fs from 'node:fs';
14
+
15
+ const SERVICE = 'morphed-agent-hook';
16
+ const CONFIG_DIR = path.join(os.homedir(), '.morphed');
17
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'agent-hook.json');
18
+
19
+ async function loadKeytar() {
20
+ try {
21
+ const mod = await import('keytar');
22
+ return mod.default || mod;
23
+ } catch {
24
+ return null; // native module unavailable → file fallback
25
+ }
26
+ }
27
+
28
+ function readConfigFile() {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
31
+ } catch {
32
+ return {};
33
+ }
34
+ }
35
+
36
+ function writeConfigFile(cfg) {
37
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
38
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
39
+ try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* windows */ }
40
+ }
41
+
42
+ /** Persist apiBase + portalId (non-secret) and the secret (keychain or file). */
43
+ export async function storeCredentials({ portalId, apiBase, secret }) {
44
+ const cfg = readConfigFile();
45
+ cfg.portalId = portalId;
46
+ cfg.apiBase = apiBase;
47
+ const keytar = await loadKeytar();
48
+ if (keytar && secret) {
49
+ await keytar.setPassword(SERVICE, String(portalId), String(secret));
50
+ cfg.secretStore = 'keychain';
51
+ delete cfg.secret;
52
+ } else if (secret) {
53
+ cfg.secretStore = 'file';
54
+ cfg.secret = secret; // file is 0600
55
+ }
56
+ writeConfigFile(cfg);
57
+ return { secretStore: cfg.secretStore };
58
+ }
59
+
60
+ /** Resolve { apiBase, portalId, secret } for the hook at runtime. */
61
+ export async function loadCredentials() {
62
+ // Env overrides win (useful for CI / managed/org installs).
63
+ const envPortal = process.env.MORPHED_PORTAL_ID;
64
+ const envSecret = process.env.MORPHED_API_KEY;
65
+ const envBase = process.env.MORPHED_API_BASE;
66
+ const cfg = readConfigFile();
67
+ const portalId = envPortal || cfg.portalId || null;
68
+ const apiBase = envBase || cfg.apiBase || 'https://api.morphed.io';
69
+ if (envSecret) return { portalId, apiBase, secret: envSecret };
70
+
71
+ if (cfg.secretStore === 'keychain' && portalId) {
72
+ const keytar = await loadKeytar();
73
+ if (keytar) {
74
+ const secret = await keytar.getPassword(SERVICE, String(portalId));
75
+ if (secret) return { portalId, apiBase, secret };
76
+ }
77
+ }
78
+ return { portalId, apiBase, secret: cfg.secret || null };
79
+ }
80
+
81
+ export const paths = { CONFIG_DIR, CONFIG_FILE, SERVICE };
82
+ export default { storeCredentials, loadCredentials, paths };
package/src/shapes.mjs ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Agent hook output shapes — Mechanism C.
3
+ *
4
+ * Translates a Morphed decision into the allow/deny JSON each agent runtime
5
+ * expects from a pre-tool hook. Verified against code.claude.com/docs (Claude
6
+ * Code) and developers.openai.com/codex (Codex), 29 May 2026.
7
+ *
8
+ * Claude Code (PreToolUse):
9
+ * { hookSpecificOutput: { hookEventName:"PreToolUse",
10
+ * permissionDecision:"allow|deny|ask", permissionDecisionReason } }
11
+ *
12
+ * Codex (PreToolUse): same hookSpecificOutput shape.
13
+ * Codex (PermissionRequest):
14
+ * { hookSpecificOutput: { hookEventName:"PermissionRequest",
15
+ * decision: { behavior:"allow|deny", message } } }
16
+ */
17
+
18
+ // Map a Morphed decision verb → an agent permission verb.
19
+ // approved → allow
20
+ // approval_required → deny (the agent must stop and escalate to Morphed)
21
+ // blocked → deny
22
+ // modify → ask (let the user/agent reconsider with the corrected state)
23
+ export function permissionFor(decision) {
24
+ switch (decision) {
25
+ case 'approved': return 'allow';
26
+ case 'modify': return 'ask';
27
+ case 'blocked':
28
+ case 'approval_required':
29
+ default: return 'deny';
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Deny/escalate copy. Phrased so the agent STOPS cleanly rather than going idle
35
+ * or silently looping on a block (the known "agent goes idle on block" quirk):
36
+ * it states the verdict, says do-not-retry, and points to the resolution path.
37
+ */
38
+ export function reasonText({ decision, reason, hold_token }) {
39
+ const base = reason || 'Morphed policy gates this write.';
40
+ if (decision === 'approval_required') {
41
+ return `Morphed requires human approval before this write. ${base} ` +
42
+ `STOP now — do not retry or work around it. A request has been opened in Morphed` +
43
+ `${hold_token ? ` (hold ${hold_token.slice(0, 12)}…)` : ''}; resume once it is approved.`;
44
+ }
45
+ if (decision === 'blocked') {
46
+ return `Morphed blocked this write. ${base} ` +
47
+ `STOP — do not retry with a variation. If this is wrong, ask the operator to adjust the policy in Morphed.`;
48
+ }
49
+ if (decision === 'modify') {
50
+ return `Morphed would modify this write to stay within policy. ${base} ` +
51
+ `Reconsider using the corrected value, or request approval in Morphed.`;
52
+ }
53
+ return base;
54
+ }
55
+
56
+ /**
57
+ * Build the stdout JSON for a given agent surface + Morphed decision.
58
+ * @param {'claude_code'|'codex'} surface
59
+ * @param {object} decision the /v1/decision response
60
+ * @param {string} hookEventName e.g. PreToolUse | PermissionRequest
61
+ */
62
+ export function buildHookOutput(surface, decision, hookEventName = 'PreToolUse') {
63
+ const permission = permissionFor(decision.decision);
64
+ const message = reasonText(decision);
65
+
66
+ // Codex PermissionRequest uses the decision.behavior nesting.
67
+ if (surface === 'codex' && hookEventName === 'PermissionRequest') {
68
+ return {
69
+ hookSpecificOutput: {
70
+ hookEventName: 'PermissionRequest',
71
+ decision: { behavior: permission === 'ask' ? 'deny' : permission, message },
72
+ },
73
+ };
74
+ }
75
+
76
+ // Claude Code + Codex PreToolUse share the hookSpecificOutput shape.
77
+ const out = {
78
+ hookSpecificOutput: {
79
+ hookEventName: hookEventName || 'PreToolUse',
80
+ permissionDecision: permission,
81
+ permissionDecisionReason: message,
82
+ },
83
+ };
84
+
85
+ // Claude Code supports `updatedInput` on PreToolUse: for a Morphed `modify`
86
+ // decision with a corrected state, rewrite the tool input and allow it
87
+ // (corrected-before) instead of just asking — verified on code.claude.com.
88
+ if (surface === 'claude_code' && decision.decision === 'modify' && decision.modified_state) {
89
+ out.hookSpecificOutput.permissionDecision = 'allow';
90
+ out.hookSpecificOutput.updatedInput = { properties: decision.modified_state };
91
+ out.hookSpecificOutput.permissionDecisionReason =
92
+ `Morphed corrected this write to stay within policy: ${decision.reason || ''}`.trim();
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * Translate a raw pre-tool payload into a /v1/decision request body.
99
+ * tool_name + tool_input vary by tool; we extract the most decision-relevant
100
+ * signal (the changed field + proposed value for HubSpot writes; the command
101
+ * for Bash) and let the decision service score it.
102
+ */
103
+ export function toDecisionInput(payload, surface) {
104
+ const toolName = payload.tool_name || payload.toolName || '';
105
+ const toolInput = payload.tool_input || payload.toolInput || {};
106
+ const isHubspot = /hubspot/i.test(toolName);
107
+
108
+ // HubSpot MCP write tools carry { objectType, objectId, properties }.
109
+ if (isHubspot) {
110
+ const objectType = toolInput.objectType || toolInput.object_type || toolInput.objectTypeId || null;
111
+ const objectId = toolInput.objectId || toolInput.object_id || toolInput.id || null;
112
+ const props = toolInput.properties || toolInput.propertiesToUpdate || {};
113
+ const field = Object.keys(props || {})[0] || toolInput.propertyName || null;
114
+ const action = /delete|archive/i.test(toolName) ? 'delete' : 'set_property';
115
+ return {
116
+ surface, firewall_phase: 'before', object_type: objectType, object_id: objectId, action, field,
117
+ proposed_state: Object.keys(props || {}).length ? props : (field && toolInput.propertyValue !== undefined ? { [field]: toolInput.propertyValue } : null),
118
+ actor: surface,
119
+ };
120
+ }
121
+
122
+ // Bash / raw CLI — catches curl-to-HubSpot and CLI writes. No structured
123
+ // field, so the decision service evaluates the action/command generically.
124
+ return {
125
+ surface,
126
+ firewall_phase: 'before', // the hook denies before the tool fires
127
+ object_type: null,
128
+ object_id: null,
129
+ action: toolName === 'Bash' || toolName === 'bash' ? 'bash' : toolName,
130
+ field: null,
131
+ proposed_state: { command: toolInput.command || toolInput.cmd || null },
132
+ actor: surface,
133
+ };
134
+ }
135
+
136
+ export default { permissionFor, reasonText, buildHookOutput, toDecisionInput };