@aitne/daemon 0.1.2 → 0.1.4
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 +21 -0
- package/dist/adapters/whatsapp-adapter.d.ts.map +1 -1
- package/dist/adapters/whatsapp-adapter.js +0 -1
- package/dist/adapters/whatsapp-adapter.js.map +1 -1
- package/dist/api/integration-route-gate.d.ts +15 -11
- package/dist/api/integration-route-gate.d.ts.map +1 -1
- package/dist/api/integration-route-gate.js +60 -23
- package/dist/api/integration-route-gate.js.map +1 -1
- package/dist/api/json-body.d.ts +22 -7
- package/dist/api/json-body.d.ts.map +1 -1
- package/dist/api/json-body.js +27 -8
- package/dist/api/json-body.js.map +1 -1
- package/dist/api/routes/agent.d.ts.map +1 -1
- package/dist/api/routes/agent.js +18 -0
- package/dist/api/routes/agent.js.map +1 -1
- package/dist/api/routes/backends.d.ts.map +1 -1
- package/dist/api/routes/backends.js +96 -1
- package/dist/api/routes/backends.js.map +1 -1
- package/dist/api/routes/books.js +1 -1
- package/dist/api/routes/books.js.map +1 -1
- package/dist/api/routes/context.d.ts.map +1 -1
- package/dist/api/routes/context.js +13 -1
- package/dist/api/routes/context.js.map +1 -1
- package/dist/api/routes/dashboard.d.ts.map +1 -1
- package/dist/api/routes/dashboard.js +75 -5
- package/dist/api/routes/dashboard.js.map +1 -1
- package/dist/api/routes/github.d.ts.map +1 -1
- package/dist/api/routes/github.js +38 -5
- package/dist/api/routes/github.js.map +1 -1
- package/dist/api/routes/integrations.d.ts +35 -6
- package/dist/api/routes/integrations.d.ts.map +1 -1
- package/dist/api/routes/integrations.js +191 -16
- package/dist/api/routes/integrations.js.map +1 -1
- package/dist/api/routes/mail.d.ts.map +1 -1
- package/dist/api/routes/mail.js +112 -46
- package/dist/api/routes/mail.js.map +1 -1
- package/dist/api/routes/observations.d.ts.map +1 -1
- package/dist/api/routes/observations.js +161 -8
- package/dist/api/routes/observations.js.map +1 -1
- package/dist/api/routes/setup-migrate.d.ts +9 -1
- package/dist/api/routes/setup-migrate.d.ts.map +1 -1
- package/dist/api/routes/setup-migrate.js +4 -2
- package/dist/api/routes/setup-migrate.js.map +1 -1
- package/dist/api/routes/skills.d.ts.map +1 -1
- package/dist/api/routes/skills.js +39 -1
- package/dist/api/routes/skills.js.map +1 -1
- package/dist/api/routes/voice.d.ts.map +1 -1
- package/dist/api/routes/voice.js +154 -14
- package/dist/api/routes/voice.js.map +1 -1
- package/dist/bootstrap/adapters.d.ts +109 -0
- package/dist/bootstrap/adapters.d.ts.map +1 -0
- package/dist/bootstrap/adapters.js +237 -0
- package/dist/bootstrap/adapters.js.map +1 -0
- package/dist/bootstrap/catchup.d.ts +23 -0
- package/dist/bootstrap/catchup.d.ts.map +1 -0
- package/dist/bootstrap/catchup.js +124 -0
- package/dist/bootstrap/catchup.js.map +1 -0
- package/dist/bootstrap/schedule-helpers.d.ts +18 -0
- package/dist/bootstrap/schedule-helpers.d.ts.map +1 -0
- package/dist/bootstrap/schedule-helpers.js +96 -0
- package/dist/bootstrap/schedule-helpers.js.map +1 -0
- package/dist/bootstrap/services.d.ts +60 -0
- package/dist/bootstrap/services.d.ts.map +1 -0
- package/dist/bootstrap/services.js +209 -0
- package/dist/bootstrap/services.js.map +1 -0
- package/dist/core/backends/backend-router.d.ts +23 -0
- package/dist/core/backends/backend-router.d.ts.map +1 -1
- package/dist/core/backends/backend-router.js +48 -3
- package/dist/core/backends/backend-router.js.map +1 -1
- package/dist/core/backends/claude-auth.d.ts +70 -0
- package/dist/core/backends/claude-auth.d.ts.map +1 -0
- package/dist/core/backends/claude-auth.js +198 -0
- package/dist/core/backends/claude-auth.js.map +1 -0
- package/dist/core/backends/claude-code-core.d.ts +47 -119
- package/dist/core/backends/claude-code-core.d.ts.map +1 -1
- package/dist/core/backends/claude-code-core.js +112 -1565
- package/dist/core/backends/claude-code-core.js.map +1 -1
- package/dist/core/backends/claude-delegated.d.ts +86 -0
- package/dist/core/backends/claude-delegated.d.ts.map +1 -0
- package/dist/core/backends/claude-delegated.js +801 -0
- package/dist/core/backends/claude-delegated.js.map +1 -0
- package/dist/core/backends/claude-errors.d.ts +39 -0
- package/dist/core/backends/claude-errors.d.ts.map +1 -0
- package/dist/core/backends/claude-errors.js +71 -0
- package/dist/core/backends/claude-errors.js.map +1 -0
- package/dist/core/backends/claude-probe.d.ts +103 -0
- package/dist/core/backends/claude-probe.d.ts.map +1 -0
- package/dist/core/backends/claude-probe.js +336 -0
- package/dist/core/backends/claude-probe.js.map +1 -0
- package/dist/core/backends/claude-tool-collection.d.ts +135 -0
- package/dist/core/backends/claude-tool-collection.d.ts.map +1 -0
- package/dist/core/backends/claude-tool-collection.js +831 -0
- package/dist/core/backends/claude-tool-collection.js.map +1 -0
- package/dist/core/backends/gemini-cli-core.d.ts +21 -0
- package/dist/core/backends/gemini-cli-core.d.ts.map +1 -1
- package/dist/core/backends/gemini-cli-core.js +84 -6
- package/dist/core/backends/gemini-cli-core.js.map +1 -1
- package/dist/core/backends/prompt-utils.d.ts +1 -0
- package/dist/core/backends/prompt-utils.d.ts.map +1 -1
- package/dist/core/backends/prompt-utils.js +60 -3
- package/dist/core/backends/prompt-utils.js.map +1 -1
- package/dist/core/context-builder.d.ts +36 -12
- package/dist/core/context-builder.d.ts.map +1 -1
- package/dist/core/context-builder.js +179 -89
- package/dist/core/context-builder.js.map +1 -1
- package/dist/core/dispatcher-date-utils.d.ts +49 -0
- package/dist/core/dispatcher-date-utils.d.ts.map +1 -0
- package/dist/core/dispatcher-date-utils.js +132 -0
- package/dist/core/dispatcher-date-utils.js.map +1 -0
- package/dist/core/dispatcher-error-handling.d.ts +159 -0
- package/dist/core/dispatcher-error-handling.d.ts.map +1 -0
- package/dist/core/dispatcher-error-handling.js +393 -0
- package/dist/core/dispatcher-error-handling.js.map +1 -0
- package/dist/core/dispatcher-hourly-check.d.ts +150 -0
- package/dist/core/dispatcher-hourly-check.d.ts.map +1 -0
- package/dist/core/dispatcher-hourly-check.js +665 -0
- package/dist/core/dispatcher-hourly-check.js.map +1 -0
- package/dist/core/dispatcher-message-handler.d.ts +170 -0
- package/dist/core/dispatcher-message-handler.d.ts.map +1 -0
- package/dist/core/dispatcher-message-handler.js +1054 -0
- package/dist/core/dispatcher-message-handler.js.map +1 -0
- package/dist/core/dispatcher-morning-routine.d.ts +169 -0
- package/dist/core/dispatcher-morning-routine.d.ts.map +1 -0
- package/dist/core/dispatcher-morning-routine.js +434 -0
- package/dist/core/dispatcher-morning-routine.js.map +1 -0
- package/dist/core/dispatcher-prompt.d.ts +107 -0
- package/dist/core/dispatcher-prompt.d.ts.map +1 -0
- package/dist/core/dispatcher-prompt.js +227 -0
- package/dist/core/dispatcher-prompt.js.map +1 -0
- package/dist/core/dispatcher-repository-helpers.d.ts +39 -0
- package/dist/core/dispatcher-repository-helpers.d.ts.map +1 -0
- package/dist/core/dispatcher-repository-helpers.js +86 -0
- package/dist/core/dispatcher-repository-helpers.js.map +1 -0
- package/dist/core/dispatcher-result-processor.d.ts +145 -0
- package/dist/core/dispatcher-result-processor.d.ts.map +1 -0
- package/dist/core/dispatcher-result-processor.js +414 -0
- package/dist/core/dispatcher-result-processor.js.map +1 -0
- package/dist/core/dispatcher-scheduled-tasks.d.ts +406 -0
- package/dist/core/dispatcher-scheduled-tasks.d.ts.map +1 -0
- package/dist/core/dispatcher-scheduled-tasks.js +998 -0
- package/dist/core/dispatcher-scheduled-tasks.js.map +1 -0
- package/dist/core/dispatcher-types.d.ts +296 -0
- package/dist/core/dispatcher-types.d.ts.map +1 -0
- package/dist/core/dispatcher-types.js +106 -0
- package/dist/core/dispatcher-types.js.map +1 -0
- package/dist/core/dispatcher.d.ts +86 -610
- package/dist/core/dispatcher.d.ts.map +1 -1
- package/dist/core/dispatcher.js +293 -3542
- package/dist/core/dispatcher.js.map +1 -1
- package/dist/core/integration-health.d.ts +18 -10
- package/dist/core/integration-health.d.ts.map +1 -1
- package/dist/core/integration-health.js +31 -1
- package/dist/core/integration-health.js.map +1 -1
- package/dist/core/integration-lifecycle.d.ts +65 -0
- package/dist/core/integration-lifecycle.d.ts.map +1 -1
- package/dist/core/integration-lifecycle.js +167 -16
- package/dist/core/integration-lifecycle.js.map +1 -1
- package/dist/core/integration-main-backend.d.ts +40 -0
- package/dist/core/integration-main-backend.d.ts.map +1 -1
- package/dist/core/integration-main-backend.js +89 -2
- package/dist/core/integration-main-backend.js.map +1 -1
- package/dist/core/management-md.d.ts +51 -17
- package/dist/core/management-md.d.ts.map +1 -1
- package/dist/core/management-md.js +233 -56
- package/dist/core/management-md.js.map +1 -1
- package/dist/core/output-language-policy.d.ts +74 -0
- package/dist/core/output-language-policy.d.ts.map +1 -0
- package/dist/core/output-language-policy.js +194 -0
- package/dist/core/output-language-policy.js.map +1 -0
- package/dist/core/prompts.d.ts +1 -0
- package/dist/core/prompts.d.ts.map +1 -1
- package/dist/core/prompts.js +121 -3
- package/dist/core/prompts.js.map +1 -1
- package/dist/core/repository-management-docs.d.ts +24 -0
- package/dist/core/repository-management-docs.d.ts.map +1 -1
- package/dist/core/repository-management-docs.js +210 -26
- package/dist/core/repository-management-docs.js.map +1 -1
- package/dist/core/routine-acquisition-plan.d.ts +131 -0
- package/dist/core/routine-acquisition-plan.d.ts.map +1 -0
- package/dist/core/routine-acquisition-plan.js +268 -0
- package/dist/core/routine-acquisition-plan.js.map +1 -0
- package/dist/core/routine-fetch-window-runner.d.ts +201 -0
- package/dist/core/routine-fetch-window-runner.d.ts.map +1 -0
- package/dist/core/routine-fetch-window-runner.js +661 -0
- package/dist/core/routine-fetch-window-runner.js.map +1 -0
- package/dist/core/routine-windows.d.ts +156 -0
- package/dist/core/routine-windows.d.ts.map +1 -0
- package/dist/core/routine-windows.js +330 -0
- package/dist/core/routine-windows.js.map +1 -0
- package/dist/core/skills-compiler.d.ts +11 -0
- package/dist/core/skills-compiler.d.ts.map +1 -1
- package/dist/core/skills-compiler.js +102 -13
- package/dist/core/skills-compiler.js.map +1 -1
- package/dist/core/skills-manifest.d.ts.map +1 -1
- package/dist/core/skills-manifest.js +26 -0
- package/dist/core/skills-manifest.js.map +1 -1
- package/dist/core/system-reset.d.ts.map +1 -1
- package/dist/core/system-reset.js +25 -2
- package/dist/core/system-reset.js.map +1 -1
- package/dist/db/observations.d.ts +45 -2
- package/dist/db/observations.d.ts.map +1 -1
- package/dist/db/observations.js +112 -14
- package/dist/db/observations.js.map +1 -1
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +13 -25
- package/dist/db/schema.js.map +1 -1
- package/dist/index.js +83 -610
- package/dist/index.js.map +1 -1
- package/dist/observers/delegated-sync-worker.d.ts +45 -2
- package/dist/observers/delegated-sync-worker.d.ts.map +1 -1
- package/dist/observers/delegated-sync-worker.js +71 -21
- package/dist/observers/delegated-sync-worker.js.map +1 -1
- package/dist/observers/mail-poller.d.ts +12 -5
- package/dist/observers/mail-poller.d.ts.map +1 -1
- package/dist/observers/mail-poller.js +36 -14
- package/dist/observers/mail-poller.js.map +1 -1
- package/dist/observers/manager.d.ts +37 -5
- package/dist/observers/manager.d.ts.map +1 -1
- package/dist/observers/manager.js +28 -10
- package/dist/observers/manager.js.map +1 -1
- package/dist/safety/risk-classifier.d.ts.map +1 -1
- package/dist/safety/risk-classifier.js +5 -0
- package/dist/safety/risk-classifier.js.map +1 -1
- package/dist/services/delegated-backend-invoker.d.ts +1 -51
- package/dist/services/delegated-backend-invoker.d.ts.map +1 -1
- package/dist/services/delegated-backend-invoker.js +41 -480
- package/dist/services/delegated-backend-invoker.js.map +1 -1
- package/dist/services/delegated-invoker-audit.d.ts +94 -0
- package/dist/services/delegated-invoker-audit.d.ts.map +1 -0
- package/dist/services/delegated-invoker-audit.js +238 -0
- package/dist/services/delegated-invoker-audit.js.map +1 -0
- package/dist/services/delegated-invoker-cache-hits.d.ts +34 -0
- package/dist/services/delegated-invoker-cache-hits.d.ts.map +1 -0
- package/dist/services/delegated-invoker-cache-hits.js +104 -0
- package/dist/services/delegated-invoker-cache-hits.js.map +1 -0
- package/dist/services/delegated-invoker-janitors.d.ts +28 -0
- package/dist/services/delegated-invoker-janitors.d.ts.map +1 -0
- package/dist/services/delegated-invoker-janitors.js +104 -0
- package/dist/services/delegated-invoker-janitors.js.map +1 -0
- package/dist/services/delegated-invoker-utils.d.ts +42 -0
- package/dist/services/delegated-invoker-utils.d.ts.map +1 -0
- package/dist/services/delegated-invoker-utils.js +100 -0
- package/dist/services/delegated-invoker-utils.js.map +1 -0
- package/dist/services/delegated-task-runtime.d.ts +1 -1
- package/dist/services/delegated-task-runtime.js +1 -1
- package/dist/services/integrations/snapshot-partitions.d.ts +5 -0
- package/dist/services/integrations/snapshot-partitions.d.ts.map +1 -1
- package/dist/services/integrations/snapshot-partitions.js +12 -0
- package/dist/services/integrations/snapshot-partitions.js.map +1 -1
- package/dist/services/voice/transcriber-impl.d.ts.map +1 -1
- package/dist/services/voice/transcriber-impl.js +46 -0
- package/dist/services/voice/transcriber-impl.js.map +1 -1
- package/package.json +12 -12
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude tool surface — pure helpers split out of `claude-code-core.ts` as
|
|
3
|
+
* part of the file-split plan (Tier 2, §8). Owns five responsibilities:
|
|
4
|
+
*
|
|
5
|
+
* - `getAllowedTools` — assemble the SDK `allowedTools` list from the
|
|
6
|
+
* configured default + the runtime override + any delegated- and native-
|
|
7
|
+
* integration tools the registry exposes.
|
|
8
|
+
* - `getDelegatedClaudeTools` — read the current `integrations` registry
|
|
9
|
+
* state and project it through `computeDelegatedClaudeTools`. Returns
|
|
10
|
+
* `[]` when the MCP context is not yet wired or on DB read failure.
|
|
11
|
+
* - `getNativeClaudeTools` — same shape as `getDelegatedClaudeTools` but
|
|
12
|
+
* projects through `computeNativeClaudeTools` (native-mode parallel).
|
|
13
|
+
* - `getSessionDeniedTools` — DELEGATED-MODE-V2-DESIGN.md §4.3.3 — expand
|
|
14
|
+
* per-integration `deniedTools` into namespaced tool names that the SDK
|
|
15
|
+
* rejects via `disallowedTools` regardless of the allow list.
|
|
16
|
+
* - `buildSecurityHooks` — build the PreToolUse hook record that enforces
|
|
17
|
+
* curl localhost-only, jq env/file-flag denials, context-dir chokepoint,
|
|
18
|
+
* vault write attribution, and the absolute-block audit layer.
|
|
19
|
+
*
|
|
20
|
+
* Pattern A (file-split-plan §5): each function reads its dependencies via
|
|
21
|
+
* an explicit argument record rather than `this.<field>`. The pure shape
|
|
22
|
+
* means these can be unit tested without instantiating `ClaudeCodeCore`,
|
|
23
|
+
* and lets tests inspect the hook closures directly. Thin shims on
|
|
24
|
+
* `ClaudeCodeCore` (`private getAllowedTools(...) { return ... }`) remain
|
|
25
|
+
* for the transitional period (file-split-plan §15).
|
|
26
|
+
*/
|
|
27
|
+
import { collectSessionDeniedTools } from "@aitne/shared";
|
|
28
|
+
import { realpathSync } from "node:fs";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import { dirname, resolve as resolvePath, isAbsolute } from "node:path";
|
|
31
|
+
import { getContextDir } from "../../config.js";
|
|
32
|
+
import { readIntegrations } from "../../db/integrations-store.js";
|
|
33
|
+
import { recordAbsoluteBlockAudit } from "../../safety/absolute-block-audit.js";
|
|
34
|
+
import { classifyAbsoluteBlock } from "../../safety/always-disallowed.js";
|
|
35
|
+
import { createLogger } from "../../logging.js";
|
|
36
|
+
import { computeDelegatedClaudeTools, computeNativeClaudeTools } from "./claude-probe.js";
|
|
37
|
+
import { isPathInsideOrEqual, shellPathForms } from "../path-compat.js";
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a path through symlinks, even when the leaf does not yet exist.
|
|
40
|
+
*
|
|
41
|
+
* `fs.realpathSync` throws ENOENT on a non-existent leaf, which is the
|
|
42
|
+
* common case for a Write hook (the target file is the *next* write).
|
|
43
|
+
* Walk upwards until an existing ancestor is found, realpath that, then
|
|
44
|
+
* rejoin the missing suffix. Used by both `fileWriteHook` and
|
|
45
|
+
* `bashContextWriteHook` to defeat symlink-based bypasses that point
|
|
46
|
+
* back into the context dir.
|
|
47
|
+
*/
|
|
48
|
+
function realpathLenient(absPath) {
|
|
49
|
+
const segments = [];
|
|
50
|
+
let current = absPath;
|
|
51
|
+
// Hard ceiling on iterations so a pathological path never spins forever.
|
|
52
|
+
for (let i = 0; i < 64; i++) {
|
|
53
|
+
try {
|
|
54
|
+
const real = realpathSync(current);
|
|
55
|
+
return segments.length === 0
|
|
56
|
+
? real
|
|
57
|
+
: resolvePath(real, ...segments.reverse());
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
const parent = dirname(current);
|
|
61
|
+
if (parent === current)
|
|
62
|
+
return absPath;
|
|
63
|
+
segments.push(current.slice(parent.length).replace(/^[/\\]+/, ""));
|
|
64
|
+
current = parent;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return absPath;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Best-effort shell tokenizer for path-token scanning. Splits on
|
|
71
|
+
* whitespace while honouring single, double, and back-tick quotes; ignores
|
|
72
|
+
* shell operators (`|`, `;`, `&`, `<`, `>`, parentheses). Returns tokens
|
|
73
|
+
* with their quote wrappers stripped.
|
|
74
|
+
*
|
|
75
|
+
* Not a full shell parser — it cannot resolve variable expansions,
|
|
76
|
+
* subshells, or function definitions. Exists to surface *literal* path
|
|
77
|
+
* arguments so that an obvious form like
|
|
78
|
+
* `echo > /Users/shuto/.personal-agent/context/today.md` is caught. The
|
|
79
|
+
* absolute-block layer is the authoritative defence for the things this
|
|
80
|
+
* heuristic misses.
|
|
81
|
+
*/
|
|
82
|
+
function tokenizeShellCommand(cmd) {
|
|
83
|
+
const tokens = [];
|
|
84
|
+
const re = /"([^"]*)"|'([^']*)'|`([^`]*)`|\$\(([^)]*)\)|([^\s|;&<>()]+)/g;
|
|
85
|
+
let match;
|
|
86
|
+
while ((match = re.exec(cmd)) !== null) {
|
|
87
|
+
const tok = match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? "";
|
|
88
|
+
if (tok.length > 0)
|
|
89
|
+
tokens.push(tok);
|
|
90
|
+
}
|
|
91
|
+
return tokens;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Expand the leading `~`, `$HOME`, and `${HOME}` segments of a token
|
|
95
|
+
* to the supplied home directory. No other shell expansion is performed.
|
|
96
|
+
*/
|
|
97
|
+
function expandHomeForms(token, home) {
|
|
98
|
+
if (token === "~")
|
|
99
|
+
return home;
|
|
100
|
+
if (token.startsWith("~/"))
|
|
101
|
+
return home + token.slice(1);
|
|
102
|
+
if (token.startsWith("$HOME/"))
|
|
103
|
+
return home + token.slice(5);
|
|
104
|
+
if (token.startsWith("${HOME}/"))
|
|
105
|
+
return home + token.slice(7);
|
|
106
|
+
if (token === "$HOME" || token === "${HOME}")
|
|
107
|
+
return home;
|
|
108
|
+
return token;
|
|
109
|
+
}
|
|
110
|
+
const logger = createLogger("claude-tool-collection");
|
|
111
|
+
/** Default allowed-tools list when the dashboard override is unset. */
|
|
112
|
+
export const CLAUDE_DEFAULT_ALLOWED_TOOLS = [
|
|
113
|
+
"Read",
|
|
114
|
+
"Glob",
|
|
115
|
+
"Grep",
|
|
116
|
+
"Write",
|
|
117
|
+
"Edit",
|
|
118
|
+
"Skill", // user skills (external-services, obsidian-*, observations, ...)
|
|
119
|
+
"Bash(curl *)", // curl broadly allowed; hooks restrict to localhost
|
|
120
|
+
"Bash(git *)", // Git operations
|
|
121
|
+
"Bash(jq *)", // safe JSON post-processor for curl pipelines
|
|
122
|
+
];
|
|
123
|
+
/**
|
|
124
|
+
* Allowed tools whitelist for dontAsk permission mode.
|
|
125
|
+
*
|
|
126
|
+
* `delegatedTools` and `nativeTools` are UNION'd onto the returned list —
|
|
127
|
+
* even when `allowedToolsOverride` is set. This is a deliberate deviation
|
|
128
|
+
* from the override's otherwise-absolute "replace everything" contract (see
|
|
129
|
+
* `CRITICAL_OVERRIDE_TOOLS` in `claude-code-core.ts`, which warns but does
|
|
130
|
+
* not union). Rationale: delegated / native modes are runtime-configurable
|
|
131
|
+
* axes orthogonal to the dashboard's tool-customization override. If a user
|
|
132
|
+
* set the override before flipping an integration, silently dropping the
|
|
133
|
+
* registry-declared connector tools would break mail/calendar with a
|
|
134
|
+
* misleading "permission denied" DM. Union semantics keep the override's
|
|
135
|
+
* curation intent while letting either mode widen the surface to whatever
|
|
136
|
+
* the registry already advertised.
|
|
137
|
+
*
|
|
138
|
+
* Native and delegated lists are accepted separately (rather than a single
|
|
139
|
+
* `extraMcpTools` parameter) so callers — and tests — surface the
|
|
140
|
+
* provenance of every widening: an audit log entry with
|
|
141
|
+
* `delegatedToolCount` and `nativeToolCount` makes a misconfigured flip
|
|
142
|
+
* diagnosable without re-running the resolver.
|
|
143
|
+
*/
|
|
144
|
+
export function getAllowedTools(config, webSearchEnabled, delegatedTools = [], nativeTools = []) {
|
|
145
|
+
const base = config.allowedToolsOverride ?? [...CLAUDE_DEFAULT_ALLOWED_TOOLS];
|
|
146
|
+
const merged = new Set(base);
|
|
147
|
+
if (!config.allowedToolsOverride && webSearchEnabled) {
|
|
148
|
+
merged.add("WebSearch");
|
|
149
|
+
}
|
|
150
|
+
for (const tool of delegatedTools)
|
|
151
|
+
merged.add(tool);
|
|
152
|
+
for (const tool of nativeTools)
|
|
153
|
+
merged.add(tool);
|
|
154
|
+
return Array.from(merged);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Read the integrations record from the wired MCP context and project it
|
|
158
|
+
* through the `computeDelegatedClaudeTools` allowlist computation. Returns
|
|
159
|
+
* `[]` when the context is not yet wired (tests / startup ordering) or on
|
|
160
|
+
* DB read failure — the latter is logged as a warning so a corrupt
|
|
161
|
+
* integrations table is visible without halting the session.
|
|
162
|
+
*/
|
|
163
|
+
export function getDelegatedClaudeTools(mcpContext) {
|
|
164
|
+
if (!mcpContext)
|
|
165
|
+
return [];
|
|
166
|
+
try {
|
|
167
|
+
const integrations = readIntegrations(mcpContext.db);
|
|
168
|
+
return computeDelegatedClaudeTools(integrations);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
logger.warn({ err }, "Failed to read integrations for delegated-tool allowlist — proceeding without delegated tools");
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Sibling of `getDelegatedClaudeTools` — projects integrations record
|
|
177
|
+
* through `computeNativeClaudeTools`. Returns `[]` when the context is
|
|
178
|
+
* not yet wired or on DB read failure, matching the conservative pattern
|
|
179
|
+
* used by the delegated counterpart.
|
|
180
|
+
*
|
|
181
|
+
* Required because the SDK's `dontAsk` permission mode silently denies
|
|
182
|
+
* tools not in `allowedTools`. Native-mode skill bodies instruct the
|
|
183
|
+
* agent to call connector MCP tools directly (e.g.
|
|
184
|
+
* `mcp__claude_ai_Gmail__search_threads`), so the registry-declared tool
|
|
185
|
+
* names for every `mode === "native" && nativeBackend === "claude"` row
|
|
186
|
+
* must be pre-authorized.
|
|
187
|
+
*/
|
|
188
|
+
export function getNativeClaudeTools(mcpContext) {
|
|
189
|
+
if (!mcpContext)
|
|
190
|
+
return [];
|
|
191
|
+
try {
|
|
192
|
+
const integrations = readIntegrations(mcpContext.db);
|
|
193
|
+
return computeNativeClaudeTools(integrations);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
logger.warn({ err }, "Failed to read integrations for native-tool allowlist — proceeding without native tools");
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* DELEGATED-MODE-V2-DESIGN.md §4.3.3 — same-backend deny enforcement at
|
|
202
|
+
* the SDK boundary. For every integration whose `delegatedBackend === "claude"`,
|
|
203
|
+
* expand `state.deniedTools` against the connector's known tools and emit
|
|
204
|
+
* the namespaced names (`mcp__claude_ai_<X>__<tool>`). The SDK refuses any
|
|
205
|
+
* tool listed in `disallowedTools` regardless of `allowedTools` — hard
|
|
206
|
+
* enforcement.
|
|
207
|
+
*
|
|
208
|
+
* Returns `[]` when context isn't wired (tests / pre-startup) and on read
|
|
209
|
+
* failures, matching the conservative pattern used by
|
|
210
|
+
* `getDelegatedClaudeTools`.
|
|
211
|
+
*/
|
|
212
|
+
export function getSessionDeniedTools(mcpContext) {
|
|
213
|
+
if (!mcpContext)
|
|
214
|
+
return [];
|
|
215
|
+
try {
|
|
216
|
+
const integrations = readIntegrations(mcpContext.db);
|
|
217
|
+
const map = collectSessionDeniedTools(integrations, "claude");
|
|
218
|
+
const out = [];
|
|
219
|
+
for (const names of map.values()) {
|
|
220
|
+
for (const n of names)
|
|
221
|
+
out.push(n);
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
logger.warn({ err }, "Failed to read integrations for same-backend denied-tools — proceeding without per-integration deny");
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Security hooks:
|
|
232
|
+
* 1. Bash(curl *) — restrict to localhost Daemon API, block connection-override flags. (strict only)
|
|
233
|
+
* 2. Bash(jq *) — block file-access flags and the `env` filter (process env exfiltration). (strict only)
|
|
234
|
+
* 3. Write/Edit — block writes into the session helper dir and context dir, mark vault writes.
|
|
235
|
+
*
|
|
236
|
+
* In allow mode the curl and jq hooks are dropped, but the Write/Edit hook
|
|
237
|
+
* stays: the context-dir chokepoint exists for memory integrity (today-write
|
|
238
|
+
* lock, md_file_snapshots, CONTEXT_WRITE_PERMISSIONS), not permissions.
|
|
239
|
+
*/
|
|
240
|
+
export function buildSecurityHooks(deps, allowMode = false) {
|
|
241
|
+
const { config, writeTracker, getMcpContext } = deps;
|
|
242
|
+
const bashCurlHook = async (input) => {
|
|
243
|
+
const toolInput = input.tool_input;
|
|
244
|
+
const cmd = toolInput?.command ?? "";
|
|
245
|
+
if (/\bcurl\b/.test(cmd)) {
|
|
246
|
+
// ── Multi-request defenses (run BEFORE host/port loop) ─────────
|
|
247
|
+
// The SDK `allowedTools` glob is a prefix match against the full
|
|
248
|
+
// command, so a permitted `Bash(curl http://localhost:<port>/api/x/*)`
|
|
249
|
+
// entry still matches a chained `curl http://localhost/api/x/y ;
|
|
250
|
+
// curl http://localhost/api/notify -d @evil`. The URL host/port
|
|
251
|
+
// loop below validates every URL but does NOT count invocations
|
|
252
|
+
// or request transactions, so a second HTTP request slips through.
|
|
253
|
+
// The three rules below cap a curl-bearing command to a single
|
|
254
|
+
// HTTP request.
|
|
255
|
+
//
|
|
256
|
+
// 1. Chained curl invocations — mirrors the `cmdStart` anchor
|
|
257
|
+
// pattern in `safety/always-disallowed.ts`. Count `curl`
|
|
258
|
+
// tokens at start-of-string / after `;` / `&&` / `||` / `|` /
|
|
259
|
+
// newline / backtick / `$(`. A single `jq -n '…' | curl URL`
|
|
260
|
+
// pipeline counts as ONE curl (only the `curl` token itself
|
|
261
|
+
// is matched; the leading `jq` is not). Two or more anchored
|
|
262
|
+
// `curl` tokens → chained invocation → block.
|
|
263
|
+
const chainedCurlMatches = cmd.match(/(?:^|[;&|`\n]|\$\()\s*curl\b/g) ?? [];
|
|
264
|
+
if (chainedCurlMatches.length > 1) {
|
|
265
|
+
return {
|
|
266
|
+
decision: "block",
|
|
267
|
+
reason: `Chained curl invocations are not allowed `
|
|
268
|
+
+ `(detected ${chainedCurlMatches.length} curl commands; `
|
|
269
|
+
+ `one curl per Bash invocation).`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
// 2. `--next` / `-:` URL multiplexing — curl's `--next` (short
|
|
273
|
+
// form `-:`) starts a new transaction with reset option state
|
|
274
|
+
// inside the same invocation. The URL loop below still passes
|
|
275
|
+
// because both URLs hit the same host:port, but curl issues
|
|
276
|
+
// one HTTP request per `--next` separator. Same exfil shape
|
|
277
|
+
// as chained curl, different syntax.
|
|
278
|
+
if (/(?:^|\s)--next(?:[\s=]|$)/.test(cmd)
|
|
279
|
+
|| /(?:^|\s)-:(?:\s|$)/.test(cmd)) {
|
|
280
|
+
return {
|
|
281
|
+
decision: "block",
|
|
282
|
+
reason: "curl --next / -: (URL multiplexing) is not allowed "
|
|
283
|
+
+ "— one HTTP request per Bash invocation.",
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
// 3. Multi-positional URL targets — `curl URL1 URL2 -X PUT -d
|
|
287
|
+
// @body` sends the same options to BOTH URLs sequentially,
|
|
288
|
+
// which `--next` blocking above does not catch. Tokenize at
|
|
289
|
+
// the top level (outside paired single / double quotes) and
|
|
290
|
+
// collect tokens whose payload starts with `http://` or
|
|
291
|
+
// `https://`. URLs embedded inside `-d '…'` / `-H "…"`
|
|
292
|
+
// strings are NOT counted because their token starts with
|
|
293
|
+
// the quote, not with `http`.
|
|
294
|
+
//
|
|
295
|
+
// This also becomes the *only* set of URLs the host/port
|
|
296
|
+
// check below runs against — URLs that legitimately appear
|
|
297
|
+
// inside JSON / markdown bodies (e.g. an architecture-section
|
|
298
|
+
// description referencing `https://github.com/foo/bar`) are
|
|
299
|
+
// NOT host-checked. The previous broad regex
|
|
300
|
+
// `cmd.match(/https?:\/\/[^\s'"]+/g)` would reject such
|
|
301
|
+
// requests because the body URL was non-localhost. Heuristic
|
|
302
|
+
// limitation: URL strings unquoted as data values are a rare
|
|
303
|
+
// false-positive surface and would be host-checked; the
|
|
304
|
+
// alternative is full shell tokenization (out of scope).
|
|
305
|
+
const topLevelTokenRe = /'[^']*'|"[^"]*"|[^'"\s]+/g;
|
|
306
|
+
const topLevelUrls = [];
|
|
307
|
+
let tokenMatch;
|
|
308
|
+
while ((tokenMatch = topLevelTokenRe.exec(cmd)) !== null) {
|
|
309
|
+
if (/^https?:\/\//.test(tokenMatch[0])) {
|
|
310
|
+
topLevelUrls.push(tokenMatch[0]);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (topLevelUrls.length > 1) {
|
|
314
|
+
return {
|
|
315
|
+
decision: "block",
|
|
316
|
+
reason: `Multiple URL targets in a single curl invocation are not allowed `
|
|
317
|
+
+ `(detected ${topLevelUrls.length} top-level URL tokens; quote `
|
|
318
|
+
+ `body URLs inside -d/-H string args).`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (topLevelUrls.length === 0) {
|
|
322
|
+
return {
|
|
323
|
+
decision: "block",
|
|
324
|
+
reason: "curl command must contain an explicit localhost URL",
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
for (const url of topLevelUrls) {
|
|
328
|
+
try {
|
|
329
|
+
const parsed = new URL(url);
|
|
330
|
+
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
|
|
331
|
+
return {
|
|
332
|
+
decision: "block",
|
|
333
|
+
reason: `curl target not allowed: ${url} (host: ${parsed.hostname})`,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
const effectivePort = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
|
|
337
|
+
if (effectivePort !== String(config.apiPort)) {
|
|
338
|
+
return {
|
|
339
|
+
decision: "block",
|
|
340
|
+
reason: `curl target port not allowed: ${effectivePort}`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return {
|
|
346
|
+
decision: "block",
|
|
347
|
+
reason: `curl target URL is malformed: ${url}`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Connection-override flags — host/proxy/socket redirection that
|
|
352
|
+
// would let curl reach something other than the configured loopback
|
|
353
|
+
// HTTP endpoint.
|
|
354
|
+
if (/--connect-to|--resolve|--config\b|(?:^|\s)-[a-zA-Z]*K|--proxy\b|(?:^|\s)-[a-zA-Z]*x|--socks|--unix-socket|--abstract-unix-socket|--interface\b|--local-port\b/.test(cmd)) {
|
|
355
|
+
return {
|
|
356
|
+
decision: "block",
|
|
357
|
+
reason: "curl connection override flags not allowed " +
|
|
358
|
+
"(--connect-to, --resolve, --config, --proxy, " +
|
|
359
|
+
"--unix-socket, --abstract-unix-socket, " +
|
|
360
|
+
"--interface, --local-port)",
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
// File-read exfil flags. curl can read arbitrary files into the
|
|
364
|
+
// request body via `@<path>` in -d / --data / --form, or via the
|
|
365
|
+
// upload-file flag. The daemon API is loopback so the request
|
|
366
|
+
// body would land in `agent_actions` / notification surfaces that
|
|
367
|
+
// the agent reads back — a confused-deputy exfil.
|
|
368
|
+
//
|
|
369
|
+
// --upload-file / -T — PUT a local file as the body
|
|
370
|
+
// -d @path / --data @path — body literal from file
|
|
371
|
+
// --data-binary @path — same, raw bytes
|
|
372
|
+
// --data-raw @path — same, no escape
|
|
373
|
+
// --data-urlencode @path — same, urlencoded
|
|
374
|
+
// --data-ascii @path — same, ascii
|
|
375
|
+
// -F name=@path / --form …=@ — multipart file part
|
|
376
|
+
// -F name=<path / --form …=< — multipart text from file
|
|
377
|
+
// Short-flag combined forms (`curl -fsT /etc/passwd`) must be
|
|
378
|
+
// caught alongside the single-flag form (`curl -T /etc/passwd`).
|
|
379
|
+
// The leading `-[a-zA-Z]*` permits zero-or-more other short flags
|
|
380
|
+
// before the dangerous letter, mirroring the pattern proven for
|
|
381
|
+
// `-L`. Same shape applied to every short-flag below — without it
|
|
382
|
+
// an attacker can stuff the dangerous letter into a benign-looking
|
|
383
|
+
// flag bundle like `-fs<X>` and bypass the deny rule entirely.
|
|
384
|
+
if (/(?:^|\s)(?:--upload-file\b|-[a-zA-Z]*T(?:\s|=|$))/.test(cmd)) {
|
|
385
|
+
return {
|
|
386
|
+
decision: "block",
|
|
387
|
+
reason: "curl --upload-file / -T not allowed — would read arbitrary files",
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
// `@-` is curl's stdin marker (canonical: `-d @-` reads the body
|
|
391
|
+
// from stdin, used by pipelines like `echo $body | curl ... -d @-`).
|
|
392
|
+
// Block `@<anything-other-than-stdin-marker>`. The lookahead
|
|
393
|
+
// `(?!-["']?(?:\s|$))` lets `@-`, `@-"`, `@-'`, `@- ` through.
|
|
394
|
+
if (/(?:^|\s)(?:--data(?:-binary|-raw|-urlencode|-ascii)?|--data|-d)\s+["']?@(?!-["']?(?:\s|$))/.test(cmd)) {
|
|
395
|
+
return {
|
|
396
|
+
decision: "block",
|
|
397
|
+
reason: "curl -d/--data with `@file` syntax not allowed — reads local files",
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
// Same for the `=` separator form: `-d=@/path`, `--data-binary=@/path`.
|
|
401
|
+
if (/(?:^|\s)(?:--data(?:-binary|-raw|-urlencode|-ascii)?|--data|-d)=["']?@(?!-["']?(?:\s|$))/.test(cmd)) {
|
|
402
|
+
return {
|
|
403
|
+
decision: "block",
|
|
404
|
+
reason: "curl -d=/--data= with `@file` syntax not allowed — reads local files",
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
if (/(?:^|\s)(?:-F|--form)\s+\S*=[@<]/.test(cmd)) {
|
|
408
|
+
return {
|
|
409
|
+
decision: "block",
|
|
410
|
+
reason: "curl -F/--form with `=@file` or `=<file` syntax not allowed — reads local files",
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
// File-write flags. The agent can land bytes anywhere on disk —
|
|
414
|
+
// overwriting shims, ssh keys, shell rc files, etc. Daemon API is
|
|
415
|
+
// the sole sanctioned write path; Bash curl writes are denied.
|
|
416
|
+
//
|
|
417
|
+
// -o / --output FILE — write response to FILE
|
|
418
|
+
// -O / --remote-name — write to basename-of-URL
|
|
419
|
+
// --remote-name-all — same, for every URL
|
|
420
|
+
// -D / --dump-header FILE — write response headers
|
|
421
|
+
// -c / --cookie-jar FILE — write Set-Cookie state
|
|
422
|
+
// --trace / --trace-ascii F — write protocol trace
|
|
423
|
+
// -w / --write-out FORMAT — format-string output
|
|
424
|
+
// (`%{stderr}` writes to stderr;
|
|
425
|
+
// combined with shell redirect
|
|
426
|
+
// it's another write channel)
|
|
427
|
+
// `-o <file>` / `--output <file>` — used to download binary
|
|
428
|
+
// payloads from the daemon API (e.g. `curl -o receipt.pdf
|
|
429
|
+
// /api/receipts/1/download`). Permit only simple relative
|
|
430
|
+
// filenames so absolute (`-o /etc/passwd`) and parent-escape
|
|
431
|
+
// (`-o ../../foo`) forms are still blocked. Tilde / env-var
|
|
432
|
+
// prefixes are likewise refused because they bypass cwd
|
|
433
|
+
// containment. Quoted paths with spaces (`-o "my file"`) are
|
|
434
|
+
// ALSO rejected so a denylist regex that stops at the space
|
|
435
|
+
// inside the quotes cannot be smuggled past.
|
|
436
|
+
const hasOutputFlag = /(?:^|\s)(?:--output(?:\b|=)|-[a-zA-Z]*o(?:\s|=|$))/.test(cmd);
|
|
437
|
+
if (hasOutputFlag) {
|
|
438
|
+
// Three capture-group alternatives so quoted paths with spaces
|
|
439
|
+
// are caught — `[^\s'"]+` alone fails on `"my file"`.
|
|
440
|
+
const valueMatch = cmd.match(/(?:^|\s)(?:--output(?:\s+|=)|-o(?:\s+|=))(?:"([^"]*)"|'([^']*)'|([^\s'"]+))/);
|
|
441
|
+
const target = valueMatch?.[1] ?? valueMatch?.[2] ?? valueMatch?.[3] ?? "";
|
|
442
|
+
const isSafeRelative = target.length > 0 &&
|
|
443
|
+
!target.startsWith("/") &&
|
|
444
|
+
!target.startsWith("~") &&
|
|
445
|
+
!target.startsWith("$") &&
|
|
446
|
+
!target.split("/").includes("..") &&
|
|
447
|
+
!target.split("\\").includes("..");
|
|
448
|
+
if (!isSafeRelative) {
|
|
449
|
+
return {
|
|
450
|
+
decision: "block",
|
|
451
|
+
reason: `curl --output/-o target must be a simple relative path; ` +
|
|
452
|
+
`got: ${target || "<unparseable>"} ` +
|
|
453
|
+
`(no absolute paths, parent-dir escapes, or shell expansions).`,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (/(?:^|\s)(?:--remote-name(?:-all)?\b|-[a-zA-Z]*O(?:\s|=|$))/.test(cmd)) {
|
|
458
|
+
return {
|
|
459
|
+
decision: "block",
|
|
460
|
+
reason: "curl --remote-name/-O not allowed — would write to URL-derived path",
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
if (/(?:^|\s)(?:--dump-header\b|-[a-zA-Z]*D(?:\s|=|$))/.test(cmd)) {
|
|
464
|
+
return {
|
|
465
|
+
decision: "block",
|
|
466
|
+
reason: "curl --dump-header/-D not allowed — writes response headers to disk",
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
if (/(?:^|\s)(?:--cookie-jar\b|-[a-zA-Z]*c(?:\s|=|$))/.test(cmd)) {
|
|
470
|
+
return {
|
|
471
|
+
decision: "block",
|
|
472
|
+
reason: "curl --cookie-jar/-c not allowed — writes cookie state to disk",
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
// `--cookie` / `-b` reads cookies from a file when the value
|
|
476
|
+
// is a filename (curl's documented semantics: `-b "FILE"` if
|
|
477
|
+
// the value has no `=`). Same exfil shape as `-d @file` — the
|
|
478
|
+
// file content is sent in the request header. Allowing
|
|
479
|
+
// `-b name=value` would require parsing the value; the simpler
|
|
480
|
+
// safe stance is to refuse the flag outright since the daemon
|
|
481
|
+
// API uses bearer tokens, not cookies.
|
|
482
|
+
if (/(?:^|\s)(?:--cookie\b|-[a-zA-Z]*b(?:\s|=|$))/.test(cmd)) {
|
|
483
|
+
return {
|
|
484
|
+
decision: "block",
|
|
485
|
+
reason: "curl --cookie/-b not allowed — when the value is a path, " +
|
|
486
|
+
"the file contents are sent as the Cookie header (file read).",
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
if (/(?:^|\s)--trace(?:-ascii)?\b/.test(cmd)) {
|
|
490
|
+
return {
|
|
491
|
+
decision: "block",
|
|
492
|
+
reason: "curl --trace / --trace-ascii not allowed — writes protocol trace to disk",
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
if (/(?:^|\s)(?:--write-out\b|-[a-zA-Z]*w(?:\s|=|$))/.test(cmd)) {
|
|
496
|
+
return {
|
|
497
|
+
decision: "block",
|
|
498
|
+
reason: "curl --write-out/-w not allowed — format strings include file/stderr sinks",
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
// Cert / key file references. The daemon API is plain HTTP on
|
|
502
|
+
// loopback; none of these flags are needed for legitimate
|
|
503
|
+
// operation and they all read arbitrary files from disk.
|
|
504
|
+
if (/(?:^|\s)(?:--cert\b|--key\b|--cacert\b|--capath\b|-[a-zA-Z]*E(?:\s|=|$))/.test(cmd)) {
|
|
505
|
+
return {
|
|
506
|
+
decision: "block",
|
|
507
|
+
reason: "curl --cert/--key/--cacert/--capath/-E not allowed — read arbitrary files",
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
// Follow-redirect flags. The localhost URL check above is
|
|
511
|
+
// bypass-able if curl follows a 3xx off-localhost. The daemon
|
|
512
|
+
// never emits redirects so this flag has no legitimate use.
|
|
513
|
+
//
|
|
514
|
+
// Combined-short-flag forms (`-fsSL`, `-vL`) are caught by the
|
|
515
|
+
// `[a-zA-Z]*L` alternation; the literal `--location` and
|
|
516
|
+
// `--location-trusted` long forms are matched explicitly.
|
|
517
|
+
if (/(?:^|\s)(?:-[a-zA-Z]*L(?:\s|=|$)|--location(?:-trusted)?\b)/.test(cmd)) {
|
|
518
|
+
return {
|
|
519
|
+
decision: "block",
|
|
520
|
+
reason: "curl -L / --location not allowed — would follow redirects off localhost",
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return { continue: true };
|
|
525
|
+
};
|
|
526
|
+
const bashJqHook = async (input) => {
|
|
527
|
+
const toolInput = input.tool_input;
|
|
528
|
+
const cmd = toolInput?.command ?? "";
|
|
529
|
+
if (!/\bjq\b/.test(cmd))
|
|
530
|
+
return { continue: true };
|
|
531
|
+
// Narrow to THIS jq invocation's own args (up to the next pipe / chain op)
|
|
532
|
+
// so that later pipeline stages are not inspected by the jq rules.
|
|
533
|
+
//
|
|
534
|
+
// Known approximation: `[^|;&]*` does not respect shell quoting, so a
|
|
535
|
+
// jq filter with a `|` INSIDE a quoted expression (e.g. `jq 'env | keys'`)
|
|
536
|
+
// will truncate `jqPart` at the first `|` regardless of whether that `|`
|
|
537
|
+
// is a jq pipe inside quotes or an actual shell pipeline break. This is
|
|
538
|
+
// intentionally conservative on the safe side: the env-filter check
|
|
539
|
+
// below still fires on the truncated left half (`jq 'env `), so attack
|
|
540
|
+
// payloads are still blocked. The downside is slightly reduced precision
|
|
541
|
+
// on benign expressions containing the jq `|` operator — those get
|
|
542
|
+
// scanned only up to the first pipe, not their full extent.
|
|
543
|
+
const jqMatch = cmd.match(/\bjq\b([^|;&]*)/);
|
|
544
|
+
if (!jqMatch)
|
|
545
|
+
return { continue: true };
|
|
546
|
+
const jqPart = jqMatch[0];
|
|
547
|
+
// (a) Block file-access flags — --slurpfile / --rawfile read arbitrary
|
|
548
|
+
// files, which would bypass the Read deny list (~/.ssh/**, .env, etc.).
|
|
549
|
+
if (/(?:^|\s)--slurpfile\b/.test(jqPart) || /(?:^|\s)--rawfile\b/.test(jqPart)) {
|
|
550
|
+
return {
|
|
551
|
+
decision: "block",
|
|
552
|
+
reason: "jq --slurpfile and --rawfile are not allowed " +
|
|
553
|
+
"(would bypass Read(.env) / Read(~/.ssh/**) disallow rules).",
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
// (b) Block module loading — -L <dir> + import can load filter code from
|
|
557
|
+
// the filesystem, effectively RCE inside the jq process.
|
|
558
|
+
if (/(?:^|\s)-L(?:\s|=|$)/.test(jqPart)) {
|
|
559
|
+
return {
|
|
560
|
+
decision: "block",
|
|
561
|
+
reason: "jq -L (module load path) is not allowed.",
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
// (c) Block the `env` filter. `jq env`, `jq -n env`, `jq 'env.FOO'`,
|
|
565
|
+
// `jq '. , env'` all dump the daemon's process.env to stdout. Process.env
|
|
566
|
+
// on this daemon is expected to be clean (secrets live in the keychain),
|
|
567
|
+
// but defense-in-depth: if OPENAI_API_KEY or similar is ever exported at
|
|
568
|
+
// launch, the env filter is the shortest exfil path.
|
|
569
|
+
//
|
|
570
|
+
// Heuristic: match bare `env` NOT preceded by a field-access dot or word
|
|
571
|
+
// char, and NOT followed by a word char. This matches jq's env filter
|
|
572
|
+
// (`env`, `env.HOME`, `(env)`, `env|keys`) while leaving field access
|
|
573
|
+
// like `.env`, `.env_var`, `.data.environments` untouched.
|
|
574
|
+
if (/(?:^|[^\w.])env(?!\w)/.test(jqPart)) {
|
|
575
|
+
return {
|
|
576
|
+
decision: "block",
|
|
577
|
+
reason: "jq env filter is not allowed — it dumps the daemon process " +
|
|
578
|
+
"environment, which is a known exfiltration vector for any " +
|
|
579
|
+
"secrets loaded via .env at startup.",
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
return { continue: true };
|
|
583
|
+
};
|
|
584
|
+
/**
|
|
585
|
+
* Block any Bash command that references the context-directory path.
|
|
586
|
+
*
|
|
587
|
+
* Rationale: the daemon API is the ONLY sanctioned write channel for
|
|
588
|
+
* context files — it enforces today-write-lock, md_file_snapshots,
|
|
589
|
+
* CONTEXT_WRITE_PERMISSIONS, and onPromptContextChanged. In strict mode,
|
|
590
|
+
* the allowlist (Bash narrowed to curl/git/jq) + fileWriteHook keeps
|
|
591
|
+
* this chokepoint intact. In allow mode Bash is unrestricted, so an
|
|
592
|
+
* agent could bypass via `echo > today.md`, `tee`, `python -c 'open…'`,
|
|
593
|
+
* `git log … > context/…`, etc. The defence here is layered:
|
|
594
|
+
*
|
|
595
|
+
* 1. Original substring match against `shellPathForms`. Cheap and
|
|
596
|
+
* catches the obvious literal form an honest model would emit.
|
|
597
|
+
* 2. Best-effort shell tokenizer + `~`/`$HOME` expansion + symlink
|
|
598
|
+
* realpath. Catches `cd ~/.personal-agent && echo > ./context/X`
|
|
599
|
+
* (the `./context/X` token, once joined to the cwd or after a
|
|
600
|
+
* separate `cd` token is detected, lands in the context dir),
|
|
601
|
+
* `ln -s ~/.personal-agent/context /tmp/x` followed by writes
|
|
602
|
+
* to `/tmp/x/today.md`, and `~/.personal-agent/./context/X`.
|
|
603
|
+
* 3. Hard block on interpreter escape hatches (`python -c`, `node
|
|
604
|
+
* -e`, `bash -c`, etc.). Static analysis cannot see what these
|
|
605
|
+
* will do; in allow-mode Bash they are the most direct route
|
|
606
|
+
* around the chokepoint.
|
|
607
|
+
*
|
|
608
|
+
* Defence-in-depth, not authoritative: a prompt-injection-driven
|
|
609
|
+
* variable-construction attack (`P=context; D=today; cd ~/.personal-agent;
|
|
610
|
+
* echo > "$P/$D.md"`) can still slip past static analysis. The static
|
|
611
|
+
* absolute-block layer covers the highest-risk patterns; if a new
|
|
612
|
+
* shape of bypass is observed in audit, codify it here.
|
|
613
|
+
*/
|
|
614
|
+
const bashContextWriteHook = async (input) => {
|
|
615
|
+
const hookInput = input;
|
|
616
|
+
const toolInput = hookInput.tool_input;
|
|
617
|
+
const cmd = toolInput?.command ?? "";
|
|
618
|
+
if (typeof cmd !== "string" || cmd.length === 0)
|
|
619
|
+
return { continue: true };
|
|
620
|
+
const absContextDir = resolvePath(getContextDir(config));
|
|
621
|
+
const home = homedir();
|
|
622
|
+
const realContextDir = realpathLenient(absContextDir);
|
|
623
|
+
// The data dir is the context dir's parent. `cd ~/.personal-agent`
|
|
624
|
+
// followed by `echo > context/today.md` lands in context via a
|
|
625
|
+
// post-cd relative path that Layer 2 cannot resolve (the hook only
|
|
626
|
+
// sees the *initial* cwd). Treating any reference to the data dir
|
|
627
|
+
// as out-of-bounds preempts that bypass — the agent has no
|
|
628
|
+
// legitimate reason to touch the data dir directly when the daemon
|
|
629
|
+
// API is the sanctioned write channel.
|
|
630
|
+
const absDataDir = resolvePath(config.dataDir);
|
|
631
|
+
const realDataDir = realpathLenient(absDataDir);
|
|
632
|
+
// ── Layer 1: substring match against well-known path forms ──
|
|
633
|
+
const pathForms = shellPathForms(absContextDir, home);
|
|
634
|
+
for (const form of pathForms) {
|
|
635
|
+
if (cmd.includes(form)) {
|
|
636
|
+
return blockContextWrite(absContextDir, `substring match: ${form}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// ── Layer 2: tokenized realpath check ──
|
|
640
|
+
//
|
|
641
|
+
// Resolve every path-looking token to its absolute form (relative
|
|
642
|
+
// to the hook-provided cwd) and to its realpath. If either lands
|
|
643
|
+
// inside the context dir OR the data dir, block.
|
|
644
|
+
const cwd = hookInput.cwd ?? "/";
|
|
645
|
+
const tokens = tokenizeShellCommand(cmd);
|
|
646
|
+
for (const rawTok of tokens) {
|
|
647
|
+
const tok = expandHomeForms(rawTok, home);
|
|
648
|
+
if (!tok.includes("/") && !tok.includes("\\"))
|
|
649
|
+
continue;
|
|
650
|
+
// Skip URL-shaped tokens; they are not filesystem paths.
|
|
651
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(tok))
|
|
652
|
+
continue;
|
|
653
|
+
const candidate = isAbsolute(tok) ? tok : resolvePath(cwd, tok);
|
|
654
|
+
const real = realpathLenient(candidate);
|
|
655
|
+
const landsInsideContext = isPathInsideOrEqual(absContextDir, candidate) ||
|
|
656
|
+
isPathInsideOrEqual(realContextDir, real);
|
|
657
|
+
const landsInsideData = isPathInsideOrEqual(absDataDir, candidate) ||
|
|
658
|
+
isPathInsideOrEqual(realDataDir, real);
|
|
659
|
+
if (landsInsideContext || landsInsideData) {
|
|
660
|
+
return blockContextWrite(absContextDir, landsInsideContext
|
|
661
|
+
? `path token resolves into context dir: ${rawTok} → ${real}`
|
|
662
|
+
: `path token resolves into the data dir (${absDataDir}); ` +
|
|
663
|
+
`the agent should never reference the data dir directly: ${rawTok} → ${real}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// ── Layer 3: interpreter escape hatches ──
|
|
667
|
+
//
|
|
668
|
+
// `bash -c "..."`, `python -c "..."`, etc. tunnel arbitrary code
|
|
669
|
+
// through an opaque argument that static analysis cannot see into.
|
|
670
|
+
// Even in allow-mode Bash the agent should never need these — the
|
|
671
|
+
// SDK Write/Edit tools and the daemon API cover legitimate
|
|
672
|
+
// file-touching use cases. Blocking the patterns themselves is the
|
|
673
|
+
// only way to keep this hook's guarantees meaningful.
|
|
674
|
+
if (/(?:^|[\s|;&])(?:bash|sh|zsh|ksh|dash|busybox)\s+-c\b/.test(cmd) ||
|
|
675
|
+
/(?:^|[\s|;&])(?:python3?|node|ruby|perl|php|deno|bun)\s+-[ce]\b/.test(cmd)) {
|
|
676
|
+
return {
|
|
677
|
+
decision: "block",
|
|
678
|
+
reason: `Bash commands that invoke an interpreter with -c / -e are not ` +
|
|
679
|
+
`allowed. Their argument is opaque to static analysis, which ` +
|
|
680
|
+
`defeats the context-write chokepoint. Use the Write/Edit tools ` +
|
|
681
|
+
`or the daemon API at http://localhost:${config.apiPort}/api/context/.`,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
return { continue: true };
|
|
685
|
+
};
|
|
686
|
+
function blockContextWrite(absContextDir, reasonDetail) {
|
|
687
|
+
return {
|
|
688
|
+
decision: "block",
|
|
689
|
+
reason: `Bash commands that reference the context directory (${absContextDir}) are ` +
|
|
690
|
+
`not allowed. Use the daemon API: ` +
|
|
691
|
+
`GET/PUT/PATCH http://localhost:${config.apiPort}/api/context/<path>. ` +
|
|
692
|
+
`The API enforces today-write-lock, md_file_snapshots, CONTEXT_WRITE_PERMISSIONS, ` +
|
|
693
|
+
`and onPromptContextChanged — bypassing it via shell redirects or script ` +
|
|
694
|
+
`engines leaves the memory layer inconsistent. ${reasonDetail}.`,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
const fileWriteHook = async (input) => {
|
|
698
|
+
const hookInput = input;
|
|
699
|
+
const toolInput = hookInput.tool_input;
|
|
700
|
+
const rawFilePath = toolInput?.file_path;
|
|
701
|
+
if (typeof rawFilePath !== "string" || rawFilePath.length === 0) {
|
|
702
|
+
return { continue: true };
|
|
703
|
+
}
|
|
704
|
+
const filePath = rawFilePath;
|
|
705
|
+
const cwd = hookInput.cwd;
|
|
706
|
+
if (!cwd && !isAbsolute(filePath))
|
|
707
|
+
return { continue: true };
|
|
708
|
+
const absFile = resolvePath(cwd ?? "/", filePath);
|
|
709
|
+
// Resolve symlinks. A lexical containment check accepts a symlink
|
|
710
|
+
// whose target lives inside a forbidden dir, because the link
|
|
711
|
+
// itself sits outside. The kernel write follows the link, so the
|
|
712
|
+
// forbidden bytes land anyway. Realpath both sides of every
|
|
713
|
+
// comparison closes that bypass.
|
|
714
|
+
const realFile = realpathLenient(absFile);
|
|
715
|
+
// (a) Block writes into the session-local helper dir. The `curl` shim in
|
|
716
|
+
// `.pa/bin/` carries daemon-auth env at execution time; letting the model
|
|
717
|
+
// rewrite it would turn the helper into a secret exfiltration vector.
|
|
718
|
+
const absHelperDir = resolvePath(cwd ?? "/", ".pa");
|
|
719
|
+
const realHelperDir = realpathLenient(absHelperDir);
|
|
720
|
+
const withinHelperDir = isPathInsideOrEqual(absHelperDir, absFile) ||
|
|
721
|
+
isPathInsideOrEqual(realHelperDir, realFile);
|
|
722
|
+
if (withinHelperDir) {
|
|
723
|
+
return {
|
|
724
|
+
decision: "block",
|
|
725
|
+
reason: "Direct Write/Edit to .pa is forbidden. " +
|
|
726
|
+
"Session helper binaries are managed by the daemon.",
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
// (b) Block writes into the context dir.
|
|
730
|
+
const contextDir = getContextDir(config);
|
|
731
|
+
const absContextDir = resolvePath(contextDir);
|
|
732
|
+
const realContextDir = realpathLenient(absContextDir);
|
|
733
|
+
const withinContext = isPathInsideOrEqual(absContextDir, absFile) ||
|
|
734
|
+
isPathInsideOrEqual(realContextDir, realFile);
|
|
735
|
+
if (withinContext) {
|
|
736
|
+
return {
|
|
737
|
+
decision: "block",
|
|
738
|
+
reason: `Direct Write/Edit to context dir is forbidden. ` +
|
|
739
|
+
`Use the daemon API instead: ` +
|
|
740
|
+
`PUT http://localhost:${config.apiPort}/api/context/<path> (full replace) or ` +
|
|
741
|
+
`PATCH http://localhost:${config.apiPort}/api/context/<path> (section op). ` +
|
|
742
|
+
`The API enforces CONTEXT_WRITE_PERMISSIONS, morningRoutineLock, md_file_snapshots, ` +
|
|
743
|
+
`onPromptContextChanged, and expectedMtime concurrency. Path: ${absFile}` +
|
|
744
|
+
(realFile !== absFile ? ` (realpath: ${realFile})` : ""),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
// (c) Mark vault-scoped writes for observer attribution.
|
|
748
|
+
// Targets the EXTERNAL Obsidian vault; the ObsidianWatcher observer
|
|
749
|
+
// watches that path and would otherwise misattribute agent writes
|
|
750
|
+
// as user writes.
|
|
751
|
+
if (!writeTracker)
|
|
752
|
+
return { continue: true };
|
|
753
|
+
const vaultPath = config.externalObsidianVaultPath;
|
|
754
|
+
if (!vaultPath)
|
|
755
|
+
return { continue: true };
|
|
756
|
+
const absVault = resolvePath(vaultPath);
|
|
757
|
+
const realVault = realpathLenient(absVault);
|
|
758
|
+
const withinVault = isPathInsideOrEqual(absVault, absFile) ||
|
|
759
|
+
isPathInsideOrEqual(realVault, realFile);
|
|
760
|
+
if (!withinVault)
|
|
761
|
+
return { continue: true };
|
|
762
|
+
// Mark BOTH paths so the observer can match whichever form the
|
|
763
|
+
// ObsidianWatcher emits. Most filesystems report the lexical path;
|
|
764
|
+
// the realpath form is belt-and-braces.
|
|
765
|
+
writeTracker.markWriting(absFile);
|
|
766
|
+
if (realFile !== absFile)
|
|
767
|
+
writeTracker.markWriting(realFile);
|
|
768
|
+
logger.debug({ filePath: absFile, realPath: realFile }, "vault write pre-marked for observer attribution");
|
|
769
|
+
return { continue: true };
|
|
770
|
+
};
|
|
771
|
+
// EXECUTION-MODE-DESIGN.md §6 — absolute-block audit hook. Runs ahead
|
|
772
|
+
// of every other Bash/Read/Write/Edit hook in both modes. The SDK-level
|
|
773
|
+
// `disallowedTools` rejection is the authoritative block; this hook is
|
|
774
|
+
// redundant defense-in-depth that also writes the `blocked_absolute`
|
|
775
|
+
// audit row so the owner can see the layer is active.
|
|
776
|
+
const makeAbsoluteBlockHook = (toolName, argField) => async (input) => {
|
|
777
|
+
const toolInput = input.tool_input;
|
|
778
|
+
const raw = toolInput?.[argField];
|
|
779
|
+
if (typeof raw !== "string")
|
|
780
|
+
return { continue: true };
|
|
781
|
+
const match = classifyAbsoluteBlock(toolName, raw);
|
|
782
|
+
if (!match)
|
|
783
|
+
return { continue: true };
|
|
784
|
+
recordAbsoluteBlockAudit({
|
|
785
|
+
db: getMcpContext?.()?.db,
|
|
786
|
+
backend: "claude",
|
|
787
|
+
mode: config.claudeExecutionPermissionMode,
|
|
788
|
+
match,
|
|
789
|
+
toolName,
|
|
790
|
+
});
|
|
791
|
+
return {
|
|
792
|
+
decision: "block",
|
|
793
|
+
reason: `Absolute-block layer denied this ${toolName} call ` +
|
|
794
|
+
`(category: ${match.category}). This rule holds in both Safe ` +
|
|
795
|
+
`and Allow modes — see EXECUTION-MODE-DESIGN.md §6.`,
|
|
796
|
+
};
|
|
797
|
+
};
|
|
798
|
+
const bashAbsoluteBlockHook = makeAbsoluteBlockHook("Bash", "command");
|
|
799
|
+
const readAbsoluteBlockHook = makeAbsoluteBlockHook("Read", "file_path");
|
|
800
|
+
const writeAbsoluteBlockHook = makeAbsoluteBlockHook("Write", "file_path");
|
|
801
|
+
const editAbsoluteBlockHook = makeAbsoluteBlockHook("Edit", "file_path");
|
|
802
|
+
// The context-write hook is always attached to Bash — it is the only
|
|
803
|
+
// guarantee that the daemon-API chokepoint for memory files survives
|
|
804
|
+
// allow mode (where curl/jq restrictions are dropped and Bash can
|
|
805
|
+
// otherwise redirect into context/*.md freely).
|
|
806
|
+
//
|
|
807
|
+
// The absolute-block audit hook is appended LAST on every matcher
|
|
808
|
+
// (§6.3). Appended rather than prepended so existing per-index hook
|
|
809
|
+
// tests keep pointing at the same functions; semantically it is a
|
|
810
|
+
// fallback defense whose practical effect is duplicating the SDK's
|
|
811
|
+
// `disallowedTools` rejection into an `agent_actions` row.
|
|
812
|
+
return {
|
|
813
|
+
PreToolUse: [
|
|
814
|
+
{
|
|
815
|
+
matcher: "Bash",
|
|
816
|
+
hooks: allowMode
|
|
817
|
+
? [bashContextWriteHook, bashAbsoluteBlockHook]
|
|
818
|
+
: [
|
|
819
|
+
bashCurlHook,
|
|
820
|
+
bashJqHook,
|
|
821
|
+
bashContextWriteHook,
|
|
822
|
+
bashAbsoluteBlockHook,
|
|
823
|
+
],
|
|
824
|
+
},
|
|
825
|
+
{ matcher: "Write", hooks: [fileWriteHook, writeAbsoluteBlockHook] },
|
|
826
|
+
{ matcher: "Edit", hooks: [fileWriteHook, editAbsoluteBlockHook] },
|
|
827
|
+
{ matcher: "Read", hooks: [readAbsoluteBlockHook] },
|
|
828
|
+
],
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
//# sourceMappingURL=claude-tool-collection.js.map
|