heyhank 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/README.md +40 -0
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
- package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
- package/dist/assets/CronManager-DDbz-yiT.js +1 -0
- package/dist/assets/HelpPage-DMfkzERp.js +1 -0
- package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
- package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
- package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
- package/dist/assets/Playground-Fc5cdc5p.js +109 -0
- package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
- package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
- package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
- package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
- package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
- package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
- package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
- package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
- package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
- package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
- package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
- package/dist/assets/index-C8M_PUmX.css +32 -0
- package/dist/assets/index-CEqZnThB.js +204 -0
- package/dist/assets/sw-register-LSSpj6RU.js +1 -0
- package/dist/assets/time-ago-B6r_l9u1.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon-32-original.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/heyhank-mascot-poster.png +0 -0
- package/dist/heyhank-mascot.mp4 +0 -0
- package/dist/heyhank-mascot.webm +0 -0
- package/dist/icon-192-original.png +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512-original.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +21 -0
- package/dist/logo-192.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo-original.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/push-sw.js +34 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-d2a0910a.js +1 -0
- package/package.json +109 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.ts +357 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-timeout.ts +107 -0
- package/server/agent-types.ts +122 -0
- package/server/ai-validation-settings.ts +37 -0
- package/server/ai-validator.ts +181 -0
- package/server/anthropic-provider-migration.ts +48 -0
- package/server/assistant-store.ts +272 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-approve.ts +153 -0
- package/server/auto-namer.ts +36 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.ts +61 -0
- package/server/calendar-service.ts +434 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.ts +1303 -0
- package/server/codex-adapter.ts +3027 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.ts +27 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.ts +1053 -0
- package/server/cost-tracker.ts +222 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/email-service.ts +354 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +75 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.ts +170 -0
- package/server/federation/node-connection.ts +190 -0
- package/server/federation/node-manager.ts +366 -0
- package/server/federation/node-store.ts +86 -0
- package/server/federation/node-types.ts +121 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.ts +379 -0
- package/server/google-media.ts +342 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +491 -0
- package/server/internal-ai.ts +237 -0
- package/server/kill-switch.ts +99 -0
- package/server/llm-providers.ts +342 -0
- package/server/logger.ts +259 -0
- package/server/mcp-registry.ts +401 -0
- package/server/message-bus.ts +271 -0
- package/server/message-delivery.ts +128 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.ts +13 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/provider-manager.ts +111 -0
- package/server/provider-registry.ts +393 -0
- package/server/push-notifications.ts +221 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.ts +320 -0
- package/server/reminder-scheduler.ts +38 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.ts +264 -0
- package/server/routes/assistant-routes.ts +90 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/federation-routes.ts +76 -0
- package/server/routes/fs-routes.ts +622 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/llm-routes.ts +166 -0
- package/server/routes/media-routes.ts +135 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/platform-routes.ts +1379 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/provider-routes.ts +109 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +285 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/socialmedia-routes.ts +208 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes/telephony-routes.ts +259 -0
- package/server/routes.ts +1379 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.ts +457 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.ts +824 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +511 -0
- package/server/settings-manager.ts +149 -0
- package/server/shared-context.ts +157 -0
- package/server/socialmedia/adapter.ts +15 -0
- package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
- package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
- package/server/socialmedia/manager.ts +227 -0
- package/server/socialmedia/store.ts +98 -0
- package/server/socialmedia/types.ts +89 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/telephony/audio-bridge.ts +331 -0
- package/server/telephony/call-manager.ts +457 -0
- package/server/telephony/call-types.ts +108 -0
- package/server/telephony/telephony-store.ts +119 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.ts +192 -0
- package/server/usage-limits.ts +225 -0
- package/server/web-push.d.ts +51 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +121 -0
- package/server/ws-bridge.ts +1240 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// ─── Agent Types ─────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface McpServerConfigAgent {
|
|
4
|
+
type: "stdio" | "sse" | "http";
|
|
5
|
+
command?: string;
|
|
6
|
+
args?: string[];
|
|
7
|
+
url?: string;
|
|
8
|
+
env?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AgentConfig {
|
|
12
|
+
/** Unique slug-based ID (derived from name) */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Schema version for forward compat */
|
|
15
|
+
version: 1;
|
|
16
|
+
/** Human-readable name */
|
|
17
|
+
name: string;
|
|
18
|
+
/** Short description of what this agent does */
|
|
19
|
+
description: string;
|
|
20
|
+
/** Emoji or icon identifier */
|
|
21
|
+
icon?: string;
|
|
22
|
+
|
|
23
|
+
// ── Session Config ──
|
|
24
|
+
/** "claude", "codex", "ollama", "openrouter", or "gemini" */
|
|
25
|
+
backendType: "claude" | "codex" | "ollama" | "openrouter" | "gemini";
|
|
26
|
+
/** Model to use (e.g. "claude-sonnet-4-6") */
|
|
27
|
+
model: string;
|
|
28
|
+
/** Permission mode — "bypassPermissions" for Claude auto mode */
|
|
29
|
+
permissionMode: string;
|
|
30
|
+
/** Working directory path, or "temp" for an auto-created temp dir */
|
|
31
|
+
cwd: string;
|
|
32
|
+
/** Optional environment slug (references ~/.heyhank/envs/) */
|
|
33
|
+
envSlug?: string;
|
|
34
|
+
/** Extra environment variables */
|
|
35
|
+
env?: Record<string, string>;
|
|
36
|
+
/** Tool allowlist (empty = all tools) */
|
|
37
|
+
allowedTools?: string[];
|
|
38
|
+
/** Codex-specific: internet access */
|
|
39
|
+
codexInternetAccess?: boolean;
|
|
40
|
+
|
|
41
|
+
// ── Prompt ──
|
|
42
|
+
/** Prompt template. Use {{input}} as placeholder for trigger-provided input */
|
|
43
|
+
prompt: string;
|
|
44
|
+
|
|
45
|
+
// ── MCP Servers ──
|
|
46
|
+
/** MCP server configs to set on the session after CLI connects */
|
|
47
|
+
mcpServers?: Record<string, McpServerConfigAgent>;
|
|
48
|
+
|
|
49
|
+
// ── Skills ──
|
|
50
|
+
/** Skill slugs to attach (from ~/.claude/skills/) */
|
|
51
|
+
skills?: string[];
|
|
52
|
+
|
|
53
|
+
// ── Docker ──
|
|
54
|
+
/** Optional Docker container configuration */
|
|
55
|
+
container?: {
|
|
56
|
+
image?: string;
|
|
57
|
+
ports?: number[];
|
|
58
|
+
volumes?: string[];
|
|
59
|
+
initScript?: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ── Git ──
|
|
63
|
+
branch?: string;
|
|
64
|
+
createBranch?: boolean;
|
|
65
|
+
useWorktree?: boolean;
|
|
66
|
+
|
|
67
|
+
// ── Triggers ──
|
|
68
|
+
triggers?: {
|
|
69
|
+
/** Webhook trigger config */
|
|
70
|
+
webhook?: {
|
|
71
|
+
enabled: boolean;
|
|
72
|
+
/** Auto-generated secret token for URL auth */
|
|
73
|
+
secret: string;
|
|
74
|
+
};
|
|
75
|
+
/** Cron/schedule trigger config */
|
|
76
|
+
schedule?: {
|
|
77
|
+
enabled: boolean;
|
|
78
|
+
/** Cron expression or ISO datetime */
|
|
79
|
+
expression: string;
|
|
80
|
+
/** true = recurring cron, false = one-shot */
|
|
81
|
+
recurring: boolean;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// ── Tracking ──
|
|
86
|
+
enabled: boolean;
|
|
87
|
+
createdAt: number;
|
|
88
|
+
updatedAt: number;
|
|
89
|
+
lastRunAt?: number;
|
|
90
|
+
lastSessionId?: string;
|
|
91
|
+
totalRuns: number;
|
|
92
|
+
consecutiveFailures: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Input for creating an agent (without auto-generated fields) */
|
|
96
|
+
export type AgentConfigCreateInput = Omit<
|
|
97
|
+
AgentConfig,
|
|
98
|
+
"id" | "createdAt" | "updatedAt" | "totalRuns" | "consecutiveFailures" | "lastRunAt" | "lastSessionId"
|
|
99
|
+
>;
|
|
100
|
+
|
|
101
|
+
/** The portable/shareable JSON format (no internal tracking fields) */
|
|
102
|
+
export type AgentConfigExport = Omit<
|
|
103
|
+
AgentConfig,
|
|
104
|
+
"id" | "createdAt" | "updatedAt" | "totalRuns" | "consecutiveFailures" | "lastRunAt" | "lastSessionId" | "enabled"
|
|
105
|
+
>;
|
|
106
|
+
|
|
107
|
+
export interface AgentExecution {
|
|
108
|
+
/** The session ID created for this execution */
|
|
109
|
+
sessionId: string;
|
|
110
|
+
/** The agent ID that triggered this */
|
|
111
|
+
agentId: string;
|
|
112
|
+
/** Trigger type that initiated this execution */
|
|
113
|
+
triggerType: "manual" | "webhook" | "schedule";
|
|
114
|
+
/** When the execution started */
|
|
115
|
+
startedAt: number;
|
|
116
|
+
/** When the execution completed */
|
|
117
|
+
completedAt?: number;
|
|
118
|
+
/** Whether the execution succeeded */
|
|
119
|
+
success?: boolean;
|
|
120
|
+
/** Error message if it failed */
|
|
121
|
+
error?: string;
|
|
122
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getSettings } from "./settings-manager.js";
|
|
2
|
+
import { hasInternalAI } from "./internal-ai.js";
|
|
3
|
+
import type { SessionState } from "./session-types.js";
|
|
4
|
+
|
|
5
|
+
export interface EffectiveAiValidationSettings {
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
autoApprove: boolean;
|
|
8
|
+
autoDeny: boolean;
|
|
9
|
+
/** @deprecated Use hasInternalAI() instead. Kept for backward compat in ws-bridge checks. */
|
|
10
|
+
anthropicApiKey: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve effective AI validation settings for a session.
|
|
15
|
+
* Session-level overrides take priority; falls back to global settings.
|
|
16
|
+
*/
|
|
17
|
+
export function getEffectiveAiValidation(
|
|
18
|
+
sessionState: SessionState,
|
|
19
|
+
): EffectiveAiValidationSettings {
|
|
20
|
+
const global = getSettings();
|
|
21
|
+
return {
|
|
22
|
+
enabled:
|
|
23
|
+
sessionState.aiValidationEnabled != null
|
|
24
|
+
? sessionState.aiValidationEnabled
|
|
25
|
+
: global.aiValidationEnabled,
|
|
26
|
+
autoApprove:
|
|
27
|
+
sessionState.aiValidationAutoApprove != null
|
|
28
|
+
? sessionState.aiValidationAutoApprove
|
|
29
|
+
: global.aiValidationAutoApprove,
|
|
30
|
+
autoDeny:
|
|
31
|
+
sessionState.aiValidationAutoDeny != null
|
|
32
|
+
? sessionState.aiValidationAutoDeny
|
|
33
|
+
: global.aiValidationAutoDeny,
|
|
34
|
+
// For backward compat: return a truthy string if any AI provider is available
|
|
35
|
+
anthropicApiKey: hasInternalAI() ? "configured" : "",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { callInternalAI } from "./internal-ai.js";
|
|
2
|
+
|
|
3
|
+
const AI_TIMEOUT_MS = 5_000;
|
|
4
|
+
|
|
5
|
+
export type AiVerdict = "safe" | "dangerous" | "uncertain";
|
|
6
|
+
|
|
7
|
+
export interface AiValidationResult {
|
|
8
|
+
verdict: AiVerdict;
|
|
9
|
+
reason: string;
|
|
10
|
+
ruleBasedOnly: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Tools that are always safe (read-only, no side effects)
|
|
14
|
+
const SAFE_TOOLS = new Set(["Read", "Glob", "Grep", "Task"]);
|
|
15
|
+
|
|
16
|
+
// Tools that should always be shown to the user (interactive)
|
|
17
|
+
const ALWAYS_MANUAL_TOOLS = new Set(["AskUserQuestion", "ExitPlanMode"]);
|
|
18
|
+
|
|
19
|
+
// Dangerous patterns for Bash commands
|
|
20
|
+
const DANGEROUS_BASH_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
|
|
21
|
+
{ pattern: /\brm\s+(-\w*r\w*\s+(-\w*f\w*\s+)?|(-\w*f\w*\s+)?-\w*r\w*\s+)[/~.]/, reason: "Recursive delete of root, home, or current directory" },
|
|
22
|
+
{ pattern: /\|\s*(ba)?sh\b/, reason: "Piping content to shell execution" },
|
|
23
|
+
{ pattern: /\|\s*bash\b/, reason: "Piping content to bash" },
|
|
24
|
+
{ pattern: /\bcurl\b.*\|\s*(ba)?sh/, reason: "Piping remote content to shell" },
|
|
25
|
+
{ pattern: /\bwget\b.*\|\s*(ba)?sh/, reason: "Piping remote download to shell" },
|
|
26
|
+
{ pattern: /^\s*sudo\b/, reason: "Privilege escalation with sudo" },
|
|
27
|
+
{ pattern: /\bgit\s+push\s+.*(-f|--force)\b/, reason: "Force pushing to remote" },
|
|
28
|
+
{ pattern: /\bgit\s+push\s+(-f|--force)\b/, reason: "Force pushing to remote" },
|
|
29
|
+
{ pattern: /\bDROP\s+(DATABASE|TABLE)\b/i, reason: "Dropping database or table" },
|
|
30
|
+
{ pattern: /\bTRUNCATE\s+TABLE\b/i, reason: "Truncating table" },
|
|
31
|
+
{ pattern: /\bmkfs\b/, reason: "Formatting filesystem" },
|
|
32
|
+
{ pattern: /\bdd\s+if=/, reason: "Direct disk write with dd" },
|
|
33
|
+
{ pattern: /:\(\)\s*\{\s*:\|:&\s*\}\s*;?\s*:/, reason: "Fork bomb" },
|
|
34
|
+
{ pattern: /\b(shutdown|reboot|init\s+0)\b/, reason: "System shutdown or reboot" },
|
|
35
|
+
{ pattern: />\s*\/dev\/[hs]d[a-z]/, reason: "Writing to block device" },
|
|
36
|
+
{ pattern: /\bchmod\s+777\b/, reason: "Setting overly permissive file permissions" },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// Sensitive file paths for Write/Edit tools
|
|
40
|
+
const SENSITIVE_PATH_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
|
|
41
|
+
{ pattern: /\/etc\/passwd\b/, reason: "Modifying system password file" },
|
|
42
|
+
{ pattern: /\/etc\/shadow\b/, reason: "Modifying system shadow file" },
|
|
43
|
+
{ pattern: /\/etc\/sudoers\b/, reason: "Modifying sudoers file" },
|
|
44
|
+
{ pattern: /\.ssh\/authorized_keys\b/, reason: "Modifying SSH authorized keys" },
|
|
45
|
+
{ pattern: /\.ssh\/id_[a-z]+\b/, reason: "Modifying SSH private keys" },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Rule-based pre-filter: returns a verdict without making any API call,
|
|
50
|
+
* or null if the tool call needs AI evaluation.
|
|
51
|
+
*/
|
|
52
|
+
export function ruleBasedFilter(
|
|
53
|
+
toolName: string,
|
|
54
|
+
input: Record<string, unknown>,
|
|
55
|
+
): AiValidationResult | null {
|
|
56
|
+
// Always-safe read-only tools
|
|
57
|
+
if (SAFE_TOOLS.has(toolName)) {
|
|
58
|
+
return { verdict: "safe", reason: `${toolName} is a read-only tool`, ruleBasedOnly: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Always-manual interactive tools
|
|
62
|
+
if (ALWAYS_MANUAL_TOOLS.has(toolName)) {
|
|
63
|
+
return { verdict: "uncertain", reason: "Interactive tool requires user input", ruleBasedOnly: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Bash command analysis
|
|
67
|
+
if (toolName === "Bash" || toolName === "bash") {
|
|
68
|
+
const command = typeof input.command === "string" ? input.command : "";
|
|
69
|
+
for (const { pattern, reason } of DANGEROUS_BASH_PATTERNS) {
|
|
70
|
+
if (pattern.test(command)) {
|
|
71
|
+
return { verdict: "dangerous", reason, ruleBasedOnly: true };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Write/Edit sensitive path analysis
|
|
77
|
+
if (toolName === "Write" || toolName === "Edit") {
|
|
78
|
+
const filePath = typeof input.file_path === "string"
|
|
79
|
+
? input.file_path
|
|
80
|
+
: typeof input.path === "string"
|
|
81
|
+
? input.path
|
|
82
|
+
: "";
|
|
83
|
+
for (const { pattern, reason } of SENSITIVE_PATH_PATTERNS) {
|
|
84
|
+
if (pattern.test(filePath)) {
|
|
85
|
+
return { verdict: "dangerous", reason, ruleBasedOnly: true };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// No rule matched — needs AI evaluation
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const SYSTEM_PROMPT = `You are a security validator for a coding assistant. You evaluate tool calls and classify them as "safe", "dangerous", or "uncertain".
|
|
95
|
+
|
|
96
|
+
Respond with exactly one JSON object on a single line:
|
|
97
|
+
{"verdict": "safe"|"dangerous"|"uncertain", "reason": "brief explanation"}
|
|
98
|
+
|
|
99
|
+
Rules:
|
|
100
|
+
- "safe": The operation is clearly non-destructive (reading files, creating new files in project dirs, standard dev commands like npm install/test, git commit, running tests, writing code)
|
|
101
|
+
- "dangerous": The operation could cause data loss, security issues, or system damage (deleting files recursively, modifying system files, running untrusted scripts, force-pushing, dropping databases, privilege escalation)
|
|
102
|
+
- "uncertain": You cannot confidently determine safety (complex bash pipelines, unfamiliar tools, ambiguous file operations)
|
|
103
|
+
|
|
104
|
+
Be conservative: when in doubt, say "uncertain" rather than "safe".`;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Call the configured internal AI provider to evaluate a tool call.
|
|
108
|
+
*/
|
|
109
|
+
export async function aiEvaluate(
|
|
110
|
+
toolName: string,
|
|
111
|
+
input: Record<string, unknown>,
|
|
112
|
+
description?: string,
|
|
113
|
+
): Promise<AiValidationResult> {
|
|
114
|
+
const inputStr = JSON.stringify(input, null, 0);
|
|
115
|
+
const truncatedInput = inputStr.length > 1000 ? inputStr.slice(0, 1000) + "..." : inputStr;
|
|
116
|
+
let userPrompt = `Tool: ${toolName}\nInput: ${truncatedInput}`;
|
|
117
|
+
if (description) {
|
|
118
|
+
userPrompt += `\nDescription: ${description}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const result = await callInternalAI({
|
|
122
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
123
|
+
userPrompt,
|
|
124
|
+
maxTokens: 256,
|
|
125
|
+
temperature: 0,
|
|
126
|
+
timeoutMs: AI_TIMEOUT_MS,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (!result.ok) {
|
|
130
|
+
console.warn(`[ai-validator] AI evaluation failed: ${result.error}`);
|
|
131
|
+
return { verdict: "uncertain", reason: result.error || "AI evaluation failed", ruleBasedOnly: false };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return parseAiResponse(result.text);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Parse the AI model's JSON response into a structured result.
|
|
139
|
+
*/
|
|
140
|
+
export function parseAiResponse(raw: string): AiValidationResult {
|
|
141
|
+
try {
|
|
142
|
+
// Try to extract JSON from the response (the model may include extra text)
|
|
143
|
+
const jsonMatch = raw.match(/\{[^}]*"verdict"\s*:\s*"[^"]*"[^}]*\}/);
|
|
144
|
+
if (!jsonMatch) {
|
|
145
|
+
return { verdict: "uncertain", reason: "Could not parse AI response", ruleBasedOnly: false };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const parsed = JSON.parse(jsonMatch[0]) as { verdict?: string; reason?: string };
|
|
149
|
+
|
|
150
|
+
if (parsed.verdict === "safe" || parsed.verdict === "dangerous" || parsed.verdict === "uncertain") {
|
|
151
|
+
return {
|
|
152
|
+
verdict: parsed.verdict,
|
|
153
|
+
reason: typeof parsed.reason === "string" ? parsed.reason : "No reason provided",
|
|
154
|
+
ruleBasedOnly: false,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { verdict: "uncertain", reason: "Invalid AI verdict value", ruleBasedOnly: false };
|
|
159
|
+
} catch {
|
|
160
|
+
return { verdict: "uncertain", reason: "Failed to parse AI response JSON", ruleBasedOnly: false };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Main entry point: validate a permission request using rule-based filter first,
|
|
166
|
+
* then AI if needed.
|
|
167
|
+
*/
|
|
168
|
+
export async function validatePermission(
|
|
169
|
+
toolName: string,
|
|
170
|
+
input: Record<string, unknown>,
|
|
171
|
+
description?: string,
|
|
172
|
+
): Promise<AiValidationResult> {
|
|
173
|
+
// Step 1: Try rule-based filter (instant, no API call)
|
|
174
|
+
const ruleResult = ruleBasedFilter(toolName, input);
|
|
175
|
+
if (ruleResult) {
|
|
176
|
+
return ruleResult;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Step 2: Call AI for evaluation
|
|
180
|
+
return aiEvaluate(toolName, input, description);
|
|
181
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Anthropic API Key Provider Migration
|
|
2
|
+
// One-time migration: moves the legacy anthropicApiKey from settings.json
|
|
3
|
+
// into the provider system (providers.json) under the "anthropic" provider.
|
|
4
|
+
// This runs on server startup to handle the transition from legacy settings
|
|
5
|
+
// to the unified provider management system.
|
|
6
|
+
|
|
7
|
+
import { getSettings, updateSettings } from "./settings-manager.js";
|
|
8
|
+
import { getProviderConfig, upsertProviderConfig } from "./provider-manager.js";
|
|
9
|
+
|
|
10
|
+
/** Migrate legacy Anthropic API key from settings to the provider system.
|
|
11
|
+
* This is a one-time operation: once the key is moved, the legacy field is cleared. */
|
|
12
|
+
export function migrateAnthropicApiKeyToProvider(): void {
|
|
13
|
+
const settings = getSettings();
|
|
14
|
+
|
|
15
|
+
// Nothing to migrate if no legacy key
|
|
16
|
+
const legacyKey = settings.anthropicApiKey?.trim();
|
|
17
|
+
if (!legacyKey) return;
|
|
18
|
+
|
|
19
|
+
// Check if anthropic provider already has a key configured
|
|
20
|
+
const existing = getProviderConfig("anthropic");
|
|
21
|
+
if (existing && existing.envValues.ANTHROPIC_API_KEY?.trim()) {
|
|
22
|
+
// Provider already configured — just clear the legacy field
|
|
23
|
+
console.log(
|
|
24
|
+
"[anthropic-migration] Provider already configured, clearing legacy settings field.",
|
|
25
|
+
);
|
|
26
|
+
updateSettings({ anthropicApiKey: "" });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Migrate key into provider system
|
|
31
|
+
const model = settings.anthropicModel?.trim() || "";
|
|
32
|
+
upsertProviderConfig("anthropic", {
|
|
33
|
+
enabled: true,
|
|
34
|
+
envValues: { ANTHROPIC_API_KEY: legacyKey },
|
|
35
|
+
...(model ? { customModel: model } : {}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Also set as internalAiProvider if not already set
|
|
39
|
+
if (!settings.internalAiProvider?.trim()) {
|
|
40
|
+
updateSettings({ anthropicApiKey: "", internalAiProvider: "anthropic" });
|
|
41
|
+
} else {
|
|
42
|
+
updateSettings({ anthropicApiKey: "" });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log(
|
|
46
|
+
"[anthropic-migration] Migrated legacy Anthropic API key to provider system.",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// ─── Assistant Store ──────────────────────────────────────────────────────────
|
|
2
|
+
// Persistent storage for personal assistant features: todos, notes, reminders.
|
|
3
|
+
// All data stored as JSON in ~/.heyhank/assistant/
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { HEYHANK_HOME } from "./paths.js";
|
|
8
|
+
|
|
9
|
+
const ASSISTANT_DIR = join(HEYHANK_HOME, "assistant");
|
|
10
|
+
|
|
11
|
+
function ensureDir(): void {
|
|
12
|
+
if (!existsSync(ASSISTANT_DIR)) {
|
|
13
|
+
mkdirSync(ASSISTANT_DIR, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readJson<T>(filename: string, fallback: T): T {
|
|
18
|
+
ensureDir();
|
|
19
|
+
const path = join(ASSISTANT_DIR, filename);
|
|
20
|
+
try {
|
|
21
|
+
if (existsSync(path)) {
|
|
22
|
+
return JSON.parse(readFileSync(path, "utf-8")) as T;
|
|
23
|
+
}
|
|
24
|
+
} catch {}
|
|
25
|
+
return fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeJson(filename: string, data: unknown): void {
|
|
29
|
+
ensureDir();
|
|
30
|
+
writeFileSync(join(ASSISTANT_DIR, filename), JSON.stringify(data, null, 2), "utf-8");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Todos ────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export interface Todo {
|
|
36
|
+
id: string;
|
|
37
|
+
text: string;
|
|
38
|
+
priority: "high" | "medium" | "low";
|
|
39
|
+
done: boolean;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
doneAt?: string;
|
|
42
|
+
category?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function genId(): string {
|
|
46
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function listTodos(filter?: { done?: boolean; priority?: string; category?: string }): Todo[] {
|
|
50
|
+
const todos = readJson<Todo[]>("todos.json", []);
|
|
51
|
+
return todos.filter((t) => {
|
|
52
|
+
if (filter?.done !== undefined && t.done !== filter.done) return false;
|
|
53
|
+
if (filter?.priority && t.priority !== filter.priority) return false;
|
|
54
|
+
if (filter?.category && t.category !== filter.category) return false;
|
|
55
|
+
return true;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function addTodo(text: string, priority: string = "medium", category?: string): Todo {
|
|
60
|
+
const todos = readJson<Todo[]>("todos.json", []);
|
|
61
|
+
const todo: Todo = {
|
|
62
|
+
id: genId(),
|
|
63
|
+
text,
|
|
64
|
+
priority: (["high", "medium", "low"].includes(priority) ? priority : "medium") as Todo["priority"],
|
|
65
|
+
done: false,
|
|
66
|
+
createdAt: new Date().toISOString(),
|
|
67
|
+
category,
|
|
68
|
+
};
|
|
69
|
+
todos.push(todo);
|
|
70
|
+
writeJson("todos.json", todos);
|
|
71
|
+
return todo;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function completeTodo(id: string): Todo | null {
|
|
75
|
+
const todos = readJson<Todo[]>("todos.json", []);
|
|
76
|
+
const todo = todos.find((t) => t.id === id);
|
|
77
|
+
if (!todo) return null;
|
|
78
|
+
todo.done = true;
|
|
79
|
+
todo.doneAt = new Date().toISOString();
|
|
80
|
+
writeJson("todos.json", todos);
|
|
81
|
+
return todo;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function deleteTodo(id: string): boolean {
|
|
85
|
+
const todos = readJson<Todo[]>("todos.json", []);
|
|
86
|
+
const idx = todos.findIndex((t) => t.id === id);
|
|
87
|
+
if (idx === -1) return false;
|
|
88
|
+
todos.splice(idx, 1);
|
|
89
|
+
writeJson("todos.json", todos);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function updateTodo(id: string, patch: { text?: string; priority?: string; category?: string }): Todo | null {
|
|
94
|
+
const todos = readJson<Todo[]>("todos.json", []);
|
|
95
|
+
const todo = todos.find((t) => t.id === id);
|
|
96
|
+
if (!todo) return null;
|
|
97
|
+
if (patch.text) todo.text = patch.text;
|
|
98
|
+
if (patch.priority && ["high", "medium", "low"].includes(patch.priority)) {
|
|
99
|
+
todo.priority = patch.priority as Todo["priority"];
|
|
100
|
+
}
|
|
101
|
+
if (patch.category !== undefined) todo.category = patch.category;
|
|
102
|
+
writeJson("todos.json", todos);
|
|
103
|
+
return todo;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Notes ────────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export interface Note {
|
|
109
|
+
id: string;
|
|
110
|
+
title: string;
|
|
111
|
+
content: string;
|
|
112
|
+
tags: string[];
|
|
113
|
+
createdAt: string;
|
|
114
|
+
updatedAt: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function listNotes(search?: string): Note[] {
|
|
118
|
+
const notes = readJson<Note[]>("notes.json", []);
|
|
119
|
+
if (!search) return notes;
|
|
120
|
+
const q = search.toLowerCase();
|
|
121
|
+
return notes.filter((n) =>
|
|
122
|
+
n.title.toLowerCase().includes(q) ||
|
|
123
|
+
n.content.toLowerCase().includes(q) ||
|
|
124
|
+
n.tags.some((t) => t.toLowerCase().includes(q))
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function addNote(title: string, content: string, tags: string[] = []): Note {
|
|
129
|
+
const notes = readJson<Note[]>("notes.json", []);
|
|
130
|
+
const note: Note = {
|
|
131
|
+
id: genId(),
|
|
132
|
+
title,
|
|
133
|
+
content,
|
|
134
|
+
tags,
|
|
135
|
+
createdAt: new Date().toISOString(),
|
|
136
|
+
updatedAt: new Date().toISOString(),
|
|
137
|
+
};
|
|
138
|
+
notes.push(note);
|
|
139
|
+
writeJson("notes.json", notes);
|
|
140
|
+
return note;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function getNote(id: string): Note | null {
|
|
144
|
+
const notes = readJson<Note[]>("notes.json", []);
|
|
145
|
+
return notes.find((n) => n.id === id) || null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function updateNote(id: string, patch: { title?: string; content?: string; tags?: string[] }): Note | null {
|
|
149
|
+
const notes = readJson<Note[]>("notes.json", []);
|
|
150
|
+
const note = notes.find((n) => n.id === id);
|
|
151
|
+
if (!note) return null;
|
|
152
|
+
if (patch.title) note.title = patch.title;
|
|
153
|
+
if (patch.content) note.content = patch.content;
|
|
154
|
+
if (patch.tags) note.tags = patch.tags;
|
|
155
|
+
note.updatedAt = new Date().toISOString();
|
|
156
|
+
writeJson("notes.json", notes);
|
|
157
|
+
return note;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function deleteNote(id: string): boolean {
|
|
161
|
+
const notes = readJson<Note[]>("notes.json", []);
|
|
162
|
+
const idx = notes.findIndex((n) => n.id === id);
|
|
163
|
+
if (idx === -1) return false;
|
|
164
|
+
notes.splice(idx, 1);
|
|
165
|
+
writeJson("notes.json", notes);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── Reminders ────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
export interface Reminder {
|
|
172
|
+
id: string;
|
|
173
|
+
text: string;
|
|
174
|
+
triggerAt: string; // ISO datetime
|
|
175
|
+
fired: boolean;
|
|
176
|
+
createdAt: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function listReminders(includeFired = false): Reminder[] {
|
|
180
|
+
const reminders = readJson<Reminder[]>("reminders.json", []);
|
|
181
|
+
return includeFired ? reminders : reminders.filter((r) => !r.fired);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function addReminder(text: string, triggerAt: string): Reminder {
|
|
185
|
+
const reminders = readJson<Reminder[]>("reminders.json", []);
|
|
186
|
+
const reminder: Reminder = {
|
|
187
|
+
id: genId(),
|
|
188
|
+
text,
|
|
189
|
+
triggerAt,
|
|
190
|
+
fired: false,
|
|
191
|
+
createdAt: new Date().toISOString(),
|
|
192
|
+
};
|
|
193
|
+
reminders.push(reminder);
|
|
194
|
+
writeJson("reminders.json", reminders);
|
|
195
|
+
return reminder;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function fireReminder(id: string): Reminder | null {
|
|
199
|
+
const reminders = readJson<Reminder[]>("reminders.json", []);
|
|
200
|
+
const r = reminders.find((rem) => rem.id === id);
|
|
201
|
+
if (!r) return null;
|
|
202
|
+
r.fired = true;
|
|
203
|
+
writeJson("reminders.json", reminders);
|
|
204
|
+
return r;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function deleteReminder(id: string): boolean {
|
|
208
|
+
const reminders = readJson<Reminder[]>("reminders.json", []);
|
|
209
|
+
const idx = reminders.findIndex((r) => r.id === id);
|
|
210
|
+
if (idx === -1) return false;
|
|
211
|
+
reminders.splice(idx, 1);
|
|
212
|
+
writeJson("reminders.json", reminders);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Get all reminders that should have fired by now */
|
|
217
|
+
export function getDueReminders(): Reminder[] {
|
|
218
|
+
const now = new Date().toISOString();
|
|
219
|
+
const reminders = readJson<Reminder[]>("reminders.json", []);
|
|
220
|
+
return reminders.filter((r) => !r.fired && r.triggerAt <= now);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Gemini Conversations ──────��─────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
export interface GeminiConversation {
|
|
226
|
+
id: string;
|
|
227
|
+
title: string;
|
|
228
|
+
messages: Array<{ role: "user" | "gemini" | "system"; text: string; ts: number }>;
|
|
229
|
+
createdAt: string;
|
|
230
|
+
duration?: number; // seconds
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function listGeminiConversations(): GeminiConversation[] {
|
|
234
|
+
return readJson<GeminiConversation[]>("gemini-conversations.json", [])
|
|
235
|
+
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function saveGeminiConversation(
|
|
239
|
+
messages: Array<{ role: "user" | "gemini" | "system"; text: string; ts: number }>,
|
|
240
|
+
duration?: number,
|
|
241
|
+
): GeminiConversation {
|
|
242
|
+
const convos = readJson<GeminiConversation[]>("gemini-conversations.json", []);
|
|
243
|
+
// Generate title from first user message
|
|
244
|
+
const firstUser = messages.find((m) => m.role === "user");
|
|
245
|
+
const title = firstUser ? firstUser.text.slice(0, 80) : "Gemini conversation";
|
|
246
|
+
const convo: GeminiConversation = {
|
|
247
|
+
id: genId(),
|
|
248
|
+
title,
|
|
249
|
+
messages: messages.filter((m) => m.role !== "system"),
|
|
250
|
+
createdAt: new Date().toISOString(),
|
|
251
|
+
duration,
|
|
252
|
+
};
|
|
253
|
+
convos.push(convo);
|
|
254
|
+
// Keep last 100 conversations
|
|
255
|
+
if (convos.length > 100) convos.splice(0, convos.length - 100);
|
|
256
|
+
writeJson("gemini-conversations.json", convos);
|
|
257
|
+
return convo;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function getGeminiConversation(id: string): GeminiConversation | null {
|
|
261
|
+
const convos = readJson<GeminiConversation[]>("gemini-conversations.json", []);
|
|
262
|
+
return convos.find((c) => c.id === id) || null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function deleteGeminiConversation(id: string): boolean {
|
|
266
|
+
const convos = readJson<GeminiConversation[]>("gemini-conversations.json", []);
|
|
267
|
+
const idx = convos.findIndex((c) => c.id === id);
|
|
268
|
+
if (idx === -1) return false;
|
|
269
|
+
convos.splice(idx, 1);
|
|
270
|
+
writeJson("gemini-conversations.json", convos);
|
|
271
|
+
return true;
|
|
272
|
+
}
|