diff --git a/hub/.env.example b/hub/.env.example index b528fd2..49ec0cc 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -40,6 +40,17 @@ HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces" # startup unless XDG_STATE_HOME is set (then defaults to $XDG_STATE_HOME/skills). HUB_SKILL_STORE_ROOT="/var/lib/cph-hub/state/skills" +# Optional tenant-local Typst package roots. When configured, the agent +# sandbox forwards these exact paths to Typst. The preinstalled package path is +# read-only; the cache path is the only additional write location. +# Keep the preinstalled package path outside the release tree and provision it +# with the namespace layout expected by Typst, for example: +# /paradigm/paradigm-templates/0.2.20/ +# Use a separate service-writable cache path when runtime dependencies may be +# downloaded; do not make the immutable preinstalled directory the cache. +# TYPST_PACKAGE_PATH="/srv/curriculum-project-hub/typst-packages/org-a" +# TYPST_PACKAGE_CACHE_PATH="/var/cache/cph-hub/org-a/typst" + # This process is pinned to exactly one Organization. Feishu credentials are # resolved from that Organization's encrypted ACTIVE connection. HUB_SILO_ORGANIZATION_ID="" diff --git a/hub/deploy/deploy_fleet_release.sh b/hub/deploy/deploy_fleet_release.sh index a078b8f..4d83808 100755 --- a/hub/deploy/deploy_fleet_release.sh +++ b/hub/deploy/deploy_fleet_release.sh @@ -85,7 +85,7 @@ REMOTE -e "ssh ${SSH_OPTS[*]}" \ "$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/" - echo "[fleet] npm ci + build (tsc + admin-web SPA)" + echo "[fleet] npm ci (including build-time dev deps) + build (tsc + admin-web SPA)" ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.202", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.202", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.202", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.202", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.202", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.202", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.202", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.202" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.217", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.217", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.217", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.217", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.217", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.217", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.217", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.217" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -271,9 +271,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.202", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.202.tgz", - "integrity": "sha512-ujR3zDthDPkZs+AxW95iHpqLT5cuwGImsS3mVxLt1DlDij4qeTnihLX8+EpQTK+oNW9jjvFA86yKwa84fa1KYA==", + "version": "0.3.217", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.217.tgz", + "integrity": "sha512-dl119zmL1Ssyd8Fx0xfVMpss2scrGCZwf+rhZwl2lHa2dYuXVluLgqi4DUIWDj3rRYdrAvaMpjCAv6a5w07ddw==", "cpu": [ "arm64" ], @@ -284,9 +284,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.202", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.202.tgz", - "integrity": "sha512-s/RVSGgkVmIMfyt1ndR8braLLu82bARoijmt1kk8d4IptUZ0Sc+zNUWKoFXwR9XqDBu6rBbBF9RIzD02raT57w==", + "version": "0.3.217", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.217.tgz", + "integrity": "sha512-IeKL1HN8fEcRQ4uw5d02by1ThpjhRtOgfHcCTBQ2KS4JfEIHvc1VGWt6Exb2a7VHhT8uRcfjPk9urbmYayZmaw==", "cpu": [ "x64" ], @@ -297,12 +297,15 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.202", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.202.tgz", - "integrity": "sha512-a4YtRkgGYt3ogePJDW8Ts6bNW690jb9LHyZaiWXsi+zT53xCNqJB2zKPyRc7hXWOqzIk4nCfwJpjmhLzMu3WIg==", + "version": "0.3.217", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.217.tgz", + "integrity": "sha512-KtrnfEwUSCdq2cc4Pgysl+U66vqw3h7u04N5/OLHmYZ4AZYy8JcqdOaSJZ27iL2bgbAxyKwu5/9YmEk9A4IswA==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -310,12 +313,15 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.202", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.202.tgz", - "integrity": "sha512-abSb3Gah45kUNyOeKjmQ/dd1KZ4CaQz5JAr9YQxRDXoOwx8wJVx6huBIpDxjms9wyS9X5Rqxn0Lx7zFP+wV2zQ==", + "version": "0.3.217", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.217.tgz", + "integrity": "sha512-Bb4AJxqrVPouM4sYIdvX3/AO5womhe70u3Euv+6B5J2OoqcRaWarVvYevX3KRruC5TvlV2Josw14dsL5qVNL+A==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -323,12 +329,15 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.202", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.202.tgz", - "integrity": "sha512-XIvhdCWAAT4OdOA82fOJII+WH0Tf8pFckckEbJMMmOgQBKOnHT+609Pd3Ehw6zGcA9iFrhG5mY8Ncuckeo1aMw==", + "version": "0.3.217", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.217.tgz", + "integrity": "sha512-JsAQyfl4n0PR4LX0h1SxMo0raERGb8B8dvbaoNQRRSpb9A2vvcwPEjyKu0eRKHRhTvspvuD6TfNxzxrmnouX9A==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -336,12 +345,15 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.202", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.202.tgz", - "integrity": "sha512-fze5nAQL1ErcMCQNB10ILaWdM0QbJSaTQzBz8NVAy0FGW8ZL0t4Wf/VgFkfzXbfkaxmPuM1C27Dn5HiU7UDEHQ==", + "version": "0.3.217", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.217.tgz", + "integrity": "sha512-qhugNZd77vAoPMIGM8vFHlbwTltFyI1POmfyl0ZJSpc6v7RE9+5+nqL2aGbGSDsDQkEHrJasXURxIeTMn9ut2w==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -349,9 +361,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.202", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.202.tgz", - "integrity": "sha512-N1J0HRvC+8a69bqNY7+ENIYQzR0i7s+rOIGH5XtuLxvLqOnZO8LHxWEZOe8ezabGq5eZqphSCgL6vQnQQpNh+A==", + "version": "0.3.217", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.217.tgz", + "integrity": "sha512-LuaQ+PXZvIToAR81JoiGa6Me9HDma2WH2oiYlAWh43IWaXHyOqgaI1aqSM0BjDhy2UiYWTvGzAopnqPnk+jSBw==", "cpu": [ "arm64" ], @@ -362,9 +374,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.202", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.202.tgz", - "integrity": "sha512-ytLGEC1fjTSiVSoXukS+j9G+06Mi20NSzxxzlG6uE75SEB0+17tHdWUaHqd8PhH/6GPzcYx81czxWQl1MVbq4Q==", + "version": "0.3.217", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.217.tgz", + "integrity": "sha512-4r/T+ze/S/CLZ58tP4Mw52XPmsc/LOrCOd8jZOqM13FCPWdCMU2osWmszEIKGVMRG2cGsaLVDYcks5cWFqjCjw==", "cpu": [ "x64" ], @@ -2685,9 +2697,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", diff --git a/hub/src/agent/security.ts b/hub/src/agent/security.ts index 44b9ffe..de34287 100644 --- a/hub/src/agent/security.ts +++ b/hub/src/agent/security.ts @@ -33,6 +33,8 @@ const SAFE_HOST_ENV_KEYS = [ "all_proxy", "no_proxy", "NODE_USE_ENV_PROXY", + "TYPST_PACKAGE_PATH", + "TYPST_PACKAGE_CACHE_PATH", ] as const; const SANDBOX_HIDDEN_ENV_KEYS = [ @@ -134,6 +136,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv); const runtimeReadPaths = hostRuntimeReadPaths(hostEnv); + const typstCacheWritePaths = hostTypstCacheWritePaths(hostEnv); const selectedSkills = input.skills ?? []; const skillPlugin = selectedSkills.length === 0 ? null @@ -174,7 +177,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false, filesystem: { - allowWrite: [workspaceDir], + allowWrite: [...new Set([workspaceDir, ...typstCacheWritePaths])], // Reject every write path by default, then re-open only the canonical // workspace. This prevents bubblewrap's ordinary temp exceptions from // turning an unauthorized path into a successful ephemeral write. @@ -244,9 +247,30 @@ function hostRuntimeReadPaths(env: Readonly>) if (!isAbsolute(cphBin)) throw new Error("CPH_BIN must be absolute for the Agent subprocess"); platformPaths.push(resolve(cphBin)); } + platformPaths.push(...configuredTypstPackagePaths(env, ["TYPST_PACKAGE_PATH", "TYPST_PACKAGE_CACHE_PATH"])); return [...new Set(platformPaths.map((path) => resolve(path)))]; } +function hostTypstCacheWritePaths(env: Readonly>): string[] { + return configuredTypstPackagePaths(env, ["TYPST_PACKAGE_CACHE_PATH"]); +} + +function configuredTypstPackagePaths( + env: Readonly>, + names: readonly ("TYPST_PACKAGE_PATH" | "TYPST_PACKAGE_CACHE_PATH")[], +): string[] { + const paths: string[] = []; + for (const name of names) { + const packagePath = env[name]?.trim(); + if (packagePath === undefined || packagePath === "") continue; + if (!isAbsolute(packagePath)) throw new Error(`${name} must be absolute for the Agent subprocess`); + const canonical = resolve(packagePath); + if (canonical === "/") throw new Error(`${name} must not be the filesystem root`); + paths.push(canonical); + } + return paths; +} + function hostSensitiveReadPaths(env: Readonly>): string[] { const home = homedir(); const paths = [ diff --git a/hub/test/unit/agent-security.test.ts b/hub/test/unit/agent-security.test.ts index 43be8fb..d736ec9 100644 --- a/hub/test/unit/agent-security.test.ts +++ b/hub/test/unit/agent-security.test.ts @@ -32,6 +32,8 @@ describe("agent subprocess security policy", () => { ALL_PROXY: "socks5h://127.0.0.1:7890", NO_PROXY: "127.0.0.1,localhost,::1", NODE_USE_ENV_PROXY: "1", + TYPST_PACKAGE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100", + TYPST_PACKAGE_CACHE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100", DATABASE_URL: "postgresql://platform-secret", FEISHU_APP_SECRET: "feishu-secret", HUB_SESSION_SECRET: "session-secret", @@ -44,6 +46,8 @@ describe("agent subprocess security policy", () => { PATH: "/usr/local/bin:/usr/bin:/bin", LANG: "C.UTF-8", CPH_BIN: "/usr/local/bin/cph", + TYPST_PACKAGE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100", + TYPST_PACKAGE_CACHE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100", ANTHROPIC_BASE_URL: "http://127.0.0.1:43123", ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", ANTHROPIC_API_KEY: "", @@ -72,7 +76,10 @@ describe("agent subprocess security policy", () => { autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false, filesystem: { - allowWrite: [canonicalWorkspace], + allowWrite: expect.arrayContaining([ + canonicalWorkspace, + "/srv/curriculum-project-hub/typst-packages/para-26071100", + ]), denyRead: ["/"], allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]), }, @@ -85,6 +92,59 @@ describe("agent subprocess security policy", () => { }); }); + it("passes configured Typst package roots and exposes them read-only to the sandbox", async () => { + const { workspaceRoot, workspace } = await makeWorkspace(); + const packageRoot = "/srv/curriculum-project-hub/typst-packages/para-26071100"; + const cacheRoot = "/var/cache/cph-hub/para-26071100/typst"; + const policy = await createAgentSecurityPolicy({ + runId: "run-test", + workspaceRoot, + workspaceDir: workspace, + hostEnv: { + PATH: "/usr/bin:/bin", + TYPST_PACKAGE_PATH: packageRoot, + TYPST_PACKAGE_CACHE_PATH: cacheRoot, + }, + }); + const canonicalWorkspace = await realpath(workspace); + + expect(policy.env).toMatchObject({ + TYPST_PACKAGE_PATH: packageRoot, + TYPST_PACKAGE_CACHE_PATH: cacheRoot, + }); + expect(policy.sandbox.filesystem.allowRead).toEqual(expect.arrayContaining([packageRoot, cacheRoot])); + expect(policy.sandbox.filesystem.allowWrite).toEqual(expect.arrayContaining([canonicalWorkspace, cacheRoot])); + expect(policy.sandbox.filesystem.allowWrite).not.toContain(packageRoot); + }); + + it("rejects a relative Typst package root instead of silently losing package access", async () => { + const { workspaceRoot, workspace } = await makeWorkspace(); + + await expect(createAgentSecurityPolicy({ + runId: "run-test", + workspaceRoot, + workspaceDir: workspace, + hostEnv: { + PATH: "/usr/bin:/bin", + TYPST_PACKAGE_PATH: "typst-packages", + }, + })).rejects.toThrow("TYPST_PACKAGE_PATH must be absolute"); + }); + + it("rejects a Typst cache rooted at the filesystem root instead of widening writes", async () => { + const { workspaceRoot, workspace } = await makeWorkspace(); + + await expect(createAgentSecurityPolicy({ + runId: "run-test", + workspaceRoot, + workspaceDir: workspace, + hostEnv: { + PATH: "/usr/bin:/bin", + TYPST_PACKAGE_CACHE_PATH: "/", + }, + })).rejects.toThrow("TYPST_PACKAGE_CACHE_PATH must not be the filesystem root"); + }); + it("rejects provider environment keys outside the explicit protocol", async () => { const { workspaceRoot, workspace } = await makeWorkspace();