forked from EduCraft/curriculum-project-hub
fix(hub): 对齐 ADR-0018 agent 执行面边界
runner.ts: 启用 SDK 内置沙箱(bubblewrap/seatbelt),写入限制在 workspace, denyRead 敏感路径,failIfUnavailable 硬拒非沙箱运行。bypassPermissions 保留 (headless 无交互),沙箱是硬边界而非权限提示层。 install_service.sh: 默认 service user 从 root 改为 cph-hub;env 模板修正 OPENROUTER_API_KEY → ANTHROPIC_AUTH_TOKEN/ANTHROPIC_BASE_URL/ANTHROPIC_API_KEY (与 server.ts 实际读取一致);补 __BASE_DIR__ sed 替换。 cph-hub.service: AssertPathExists=/usr/bin/bwrap 强制沙箱依赖;NoNewPrivileges。 不加 ProtectSystem/ProtectHome(会破坏 cph render-cache 与 bwrap user-namespace)。 trigger.ts: 权限闸门去掉 if (senderOpenId !== '') 短路——缺 open_id 一律 fail-closed 拒绝,不再静默跳过;role gate 同步去掉冗余守卫(已由上游保证)。 顺手把 JSON.parse(msg.content) 的 inline cast 换成 Zod schema parse (FileMessageContentSchema / TextMessageContentSchema)。 ADR-0018: 状态改为 Accepted+implemented,机制段写定 SDK 沙箱,网络决定为开放, Open Questions 更新。
This commit is contained in:
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted. The decision is pinned; alignment of the current Hub
|
Accepted, and implemented. The mechanism is settled: the Claude Code SDK's
|
||||||
implementation to it is a follow-up surfaced below (constitution rule 4:
|
built-in sandbox (bubblewrap on Linux, seatbelt on macOS) confines the agent
|
||||||
implementation diverging from contract must be surfaced, not silently kept).
|
process and its Bash subprocesses at the OS level. `bypassPermissions`
|
||||||
|
remains (headless server — no interactive prompts), but the sandbox is the
|
||||||
|
hard boundary, not the permission-prompt layer. Constitution rule 4
|
||||||
|
divergence (ADR-0017's unbounded tools) is resolved.
|
||||||
|
|
||||||
Builds on **ADR-0001** (project group as collaboration space), **ADR-0002**
|
Builds on **ADR-0001** (project group as collaboration space), **ADR-0002**
|
||||||
(project lock scoped to `run_id`), **ADR-0004** (permission grants govern
|
(project lock scoped to `run_id`), **ADR-0004** (permission grants govern
|
||||||
@@ -80,14 +83,35 @@ risk is compounded by root; it is enforced in the unit, not in `spec/` (it is
|
|||||||
not a semantic divergence between developer and agent — it is operational
|
not a semantic divergence between developer and agent — it is operational
|
||||||
discipline).
|
discipline).
|
||||||
|
|
||||||
### The mechanism is deliberately left open
|
### The mechanism: SDK sandbox (OS-level, per agent process)
|
||||||
|
|
||||||
|
The boundary is enforced by the Claude Code SDK's built-in sandbox
|
||||||
|
(bubblewrap on Linux, seatbelt on macOS), configured via `query()` options in
|
||||||
|
`hub/src/agent/runner.ts`:
|
||||||
|
|
||||||
|
- `sandbox.enabled: true` + `failIfUnavailable: true` — the agent process and
|
||||||
|
its Bash subprocesses run sandboxed; if the sandbox can't start, `query()`
|
||||||
|
emits an error and exits rather than running unsandboxed.
|
||||||
|
- `sandbox.filesystem.allowWrite: [workspaceDir]` — writes confined to the
|
||||||
|
workspace (the ADR-0007 directory tree). Reads outside are allowed by
|
||||||
|
default (the agent may read system files for context), but sensitive host
|
||||||
|
paths are denied via `denyRead` (`/etc`, `/root`, `~/.ssh`, `~/.aws`, …).
|
||||||
|
- `sandbox.filesystem.denyRead: SENSITIVE_READ_PATHS` — defense-in-depth;
|
||||||
|
extends via `CPH_SANDBOX_EXTRA_DENY_READ` for deployment-specific secrets.
|
||||||
|
- Network: open (see Open Questions).
|
||||||
|
|
||||||
|
`bypassPermissions` is kept (headless server — no interactive prompts); the
|
||||||
|
sandbox is the hard boundary, not the permission-prompt layer. This is
|
||||||
|
subprocess-level, not process-level: the Hub host process is not sandboxed
|
||||||
|
(it manages DB, Feishu ws, logs), only the Claude Code agent process and its
|
||||||
|
children. This keeps the sandbox config surface small and lets future
|
||||||
|
external CLIs (Gitea, Lark) run inside the sandbox by giving their binaries
|
||||||
|
read access, without widening the Hub's own filesystem surface.
|
||||||
|
|
||||||
How the boundary is *enforced* — path validation inside tool wrappers (the
|
|
||||||
`workspace.ts` `confine()` shape), an OS-level sandbox (bubblewrap / container
|
|
||||||
/ chroot), the SDK's own permission hooks, or some combination — is `OPEN`.
|
|
||||||
The contract pins the **invariant** (operations stay in the workspace), not
|
The contract pins the **invariant** (operations stay in the workspace), not
|
||||||
the **mechanism**. Any mechanism that upholds the invariant satisfies the
|
the **mechanism**. The SDK sandbox is the current mechanism; switching to a
|
||||||
contract; switching mechanisms is not a spec change.
|
different one (tool wrappers, container) would not be a spec change as long
|
||||||
|
as it upholds `AgentFileOp.Authorized`.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
@@ -110,24 +134,30 @@ contract; switching mechanisms is not a spec change.
|
|||||||
change to the delivery path.
|
change to the delivery path.
|
||||||
- The `cph` subprocess (ADR-0016) already runs with `cwd = workspaceDir`
|
- The `cph` subprocess (ADR-0016) already runs with `cwd = workspaceDir`
|
||||||
(`hub/src/agent/cph.ts`), so it is inside the boundary by construction.
|
(`hub/src/agent/cph.ts`), so it is inside the boundary by construction.
|
||||||
- Force-release of a stuck lock (ADR-0002 admin override) does not cross the
|
|
||||||
surface boundary — it is a lock-table operation, not an agent operation.
|
|
||||||
|
|
||||||
## Open Questions / Deferred
|
## Open Questions / Deferred
|
||||||
|
|
||||||
- **Enforcement mechanism.** Tool-wrapper path validation vs OS-level sandbox
|
- **Network egress.** Decided: network is **open** in the initial
|
||||||
(bubblewrap/container) vs SDK permission hooks vs a combination. The
|
production deployment. The sandbox confines filesystem writes to the
|
||||||
contract pins the invariant; this picks the mechanism. Must be resolved
|
workspace and denies reads of sensitive host paths, but does not restrict
|
||||||
before production, since the current mechanism is "none".
|
outbound network. Rationale: future Gitea/Lark CLI integration needs
|
||||||
- **`bypassPermissions` reconciliation.** Whether the Claude Code SDK can be
|
unconstrained network egress; `cph build` may fetch typst packages. The
|
||||||
configured to confine its built-in Read/Write/Glob/Grep to a subtree and to
|
exfiltration risk (agent `curl`s workspace content out) is accepted as a
|
||||||
gate Bash, or whether the Hub must re-introduce custom tools wrapping
|
tradeoff for CLI integration simplicity. This is revisitable — a network
|
||||||
`confine()`, is the concrete implementation question ADR-0017 left open and
|
allowlist (`sandbox.network.allowedDomains`) can be added without a spec
|
||||||
this ADR now forces.
|
change if the threat model tightens.
|
||||||
|
- **Sensitive read path coverage.** `SENSITIVE_READ_PATHS` in `runner.ts`
|
||||||
|
covers `/etc`, `/root`, `/var/log`, `/proc`, `/sys`, and common
|
||||||
|
credential dirs (`~/.ssh`, `~/.aws`, `~/.config/gcloud`, `~/.gnupg`).
|
||||||
|
Deployments with additional secret locations extend via
|
||||||
|
`CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated). Whether this set should be
|
||||||
|
spec-pinned rather than implementation-chosen is `OPEN` — it's plumbing, not
|
||||||
|
a semantic divergence, so currently left out of `spec/`.
|
||||||
- **Shell command vocabulary.** Whether the agent's Bash is restricted to a
|
- **Shell command vocabulary.** Whether the agent's Bash is restricted to a
|
||||||
whitelist (e.g. `cph`, `ls`, `grep`) or allowed arbitrary commands whose
|
whitelist (e.g. `cph`, `ls`, `grep`) or allowed arbitrary commands whose
|
||||||
*effects* are then confined, is a policy choice left to implementation. The
|
*effects* are then confined, is a policy choice left to implementation. The
|
||||||
invariant governs effects either way.
|
invariant governs effects either way; current choice is arbitrary commands
|
||||||
|
(sandbox confines effects).
|
||||||
- **Per-user rate limiting and token budgets.** Orthogonal to the surface
|
- **Per-user rate limiting and token budgets.** Orthogonal to the surface
|
||||||
boundary (this ADR is about *what* the agent may touch, not *how often* it
|
boundary (this ADR is about *what* the agent may touch, not *how often* it
|
||||||
may run). Deferred to a separate concern.
|
may run). Deferred to a separate concern.
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ RestartSec=5
|
|||||||
# Graceful shutdown: lark ws close + in-flight runs.
|
# Graceful shutdown: lark ws close + in-flight runs.
|
||||||
KillSignal=SIGTERM
|
KillSignal=SIGTERM
|
||||||
TimeoutStopSec=30
|
TimeoutStopSec=30
|
||||||
|
# ADR-0018: the agent sandbox (bubblewrap) must be available on the host —
|
||||||
[Install]
|
# failIfUnavailable makes the Hub refuse to run unsandboxed. Install bwrap.
|
||||||
WantedBy=multi-user.target
|
AssertPathExists=/usr/bin/bwrap
|
||||||
|
# Hardening: no new privileges. ProtectSystem/ProtectHome are NOT set here —
|
||||||
|
# they would break cph's render-cache extraction (~/.cache/cph/) and the
|
||||||
|
# bwrap user-namespace setup. The OS-level sandbox (ADR-0018) is the hard
|
||||||
|
# boundary; systemd hardening is kept minimal to avoid interfering with it.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|||||||
@@ -7,12 +7,9 @@
|
|||||||
# PLATFORM_DEPLOY_USER optional, defaults to deploy
|
# PLATFORM_DEPLOY_USER optional, defaults to deploy
|
||||||
# PLATFORM_DEPLOY_PORT optional, defaults to 22
|
# PLATFORM_DEPLOY_PORT optional, defaults to 22
|
||||||
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
|
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
|
||||||
#
|
# The target host must have Node.js, npm, rsync, PostgreSQL, systemd, bubblewrap
|
||||||
# The target host must have Node.js, npm, rsync, PostgreSQL, systemd, and a
|
# (`bwrap`, for the ADR-0018 agent sandbox), and a writable $PLATFORM_DEPLOY_BASE.
|
||||||
# writable $PLATFORM_DEPLOY_BASE. Use a dedicated `deploy` user that has
|
# Use a dedicated `deploy` user that has passwordless sudo for:
|
||||||
# passwordless sudo for:
|
|
||||||
# systemctl restart cph-hub.service
|
|
||||||
# systemctl is-active --quiet cph-hub.service
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}"
|
HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
BASE="${BASE:-/srv/curriculum-project-hub}"
|
BASE="${BASE:-/srv/curriculum-project-hub}"
|
||||||
HUB_DIR="${HUB_DIR:-$BASE/hub}"
|
HUB_DIR="${HUB_DIR:-$BASE/hub}"
|
||||||
SERVICE_USER="${SERVICE_USER:-root}"
|
SERVICE_USER="${SERVICE_USER:-cph-hub}"
|
||||||
HOST="${HOST:-127.0.0.1}"
|
HOST="${HOST:-127.0.0.1}"
|
||||||
PORT="${PORT:-8788}"
|
PORT="${PORT:-8788}"
|
||||||
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
|
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
|
||||||
@@ -36,14 +36,18 @@ if [ ! -f "$ENV_FILE" ]; then
|
|||||||
cat >"$ENV_FILE" <<EOF
|
cat >"$ENV_FILE" <<EOF
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm
|
DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm
|
||||||
OPENROUTER_API_KEY=
|
# Claude Code SDK auth via OpenRouter's Anthropic Skin (ADR-0017).
|
||||||
|
# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key; ANTHROPIC_API_KEY must be empty.
|
||||||
|
ANTHROPIC_BASE_URL=https://openrouter.ai/api
|
||||||
|
ANTHROPIC_AUTH_TOKEN=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
FEISHU_APP_ID=
|
FEISHU_APP_ID=
|
||||||
FEISHU_APP_SECRET=
|
FEISHU_APP_SECRET=
|
||||||
FEISHU_BOT_OPEN_ID=
|
FEISHU_BOT_OPEN_ID=
|
||||||
PORT=$PORT
|
PORT=$PORT
|
||||||
HOST=$HOST
|
HOST=$HOST
|
||||||
EOF
|
EOF
|
||||||
echo "Seeded $ENV_FILE — edit it to fill in OPENROUTER_API_KEY and Feishu creds, then re-run."
|
echo "Seeded $ENV_FILE — edit it to fill in ANTHROPIC_AUTH_TOKEN and Feishu creds, then re-run."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -52,9 +56,9 @@ UNIT=/etc/systemd/system/cph-hub.service
|
|||||||
sed \
|
sed \
|
||||||
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
||||||
-e "s|__HUB_DIR__|$HUB_DIR|g" \
|
-e "s|__HUB_DIR__|$HUB_DIR|g" \
|
||||||
|
-e "s|__BASE_DIR__|$BASE|g" \
|
||||||
-e "s|__ENV_FILE__|$ENV_FILE|g" \
|
-e "s|__ENV_FILE__|$ENV_FILE|g" \
|
||||||
-e "s|__NODE_BIN__|$NODE_BIN|g" \
|
-e "s|__NODE_BIN__|$NODE_BIN|g" \
|
||||||
"$HUB_DIR/deploy/cph-hub.service" >"$UNIT"
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable cph-hub.service
|
systemctl enable cph-hub.service
|
||||||
|
|||||||
@@ -16,7 +16,18 @@
|
|||||||
* Anthropic-specific. The tradeoff: we get compaction, tool approval, partial
|
* Anthropic-specific. The tradeoff: we get compaction, tool approval, partial
|
||||||
* message streaming, and a battle-tested agent loop — capabilities we'd have
|
* message streaming, and a battle-tested agent loop — capabilities we'd have
|
||||||
* to build ourselves with a generic provider.
|
* to build ourselves with a generic provider.
|
||||||
|
*
|
||||||
|
* ADR-0018: the agent execution surface is bounded by the run's workspace.
|
||||||
|
* The SDK sandbox (bubblewrap on Linux, seatbelt on macOS) confines the Claude
|
||||||
|
* Code process and its subprocesses at the OS level — writes are confined to
|
||||||
|
* the workspace (`sandbox.filesystem.allowWrite`), sensitive host paths are
|
||||||
|
* denied reads (`denyRead`), and `failIfUnavailable` hard-fails if the sandbox
|
||||||
|
* can't start (never run unsandboxed). This upholds `AgentFileOp.Authorized`
|
||||||
|
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
|
||||||
|
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
|
||||||
|
* is the mechanism, the contract pins the invariant.
|
||||||
*/
|
*/
|
||||||
|
import { homedir } from "node:os";
|
||||||
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
@@ -61,6 +72,33 @@ export interface RunResult {
|
|||||||
|
|
||||||
const DEFAULT_MAX_TURNS = 25;
|
const DEFAULT_MAX_TURNS = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host paths the agent may not read (ADR-0018 defense-in-depth). The sandbox
|
||||||
|
* confines writes to the workspace; these deny reads of credentials/secrets
|
||||||
|
* the process could otherwise see (read-only mounts are still readable). The
|
||||||
|
* workspace itself is never in this list — it's writable by `allowWrite`.
|
||||||
|
* Extend via `CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated) for deployments
|
||||||
|
* with additional secret locations.
|
||||||
|
*/
|
||||||
|
const SENSITIVE_READ_PATHS: readonly string[] = (() => {
|
||||||
|
const home = homedir();
|
||||||
|
const base = [
|
||||||
|
"/etc",
|
||||||
|
"/root",
|
||||||
|
"/var/log",
|
||||||
|
"/proc",
|
||||||
|
"/sys",
|
||||||
|
`${home}/.ssh`,
|
||||||
|
`${home}/.aws`,
|
||||||
|
`${home}/.config/gcloud`,
|
||||||
|
`${home}/.gnupg`,
|
||||||
|
];
|
||||||
|
const extra = process.env["CPH_SANDBOX_EXTRA_DENY_READ"];
|
||||||
|
return extra !== undefined && extra !== ""
|
||||||
|
? [...base, ...extra.split(":").filter((p) => p !== "")]
|
||||||
|
: base;
|
||||||
|
})();
|
||||||
|
|
||||||
export async function runAgent(req: RunRequest): Promise<RunResult> {
|
export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||||
const onStream = req.onStream;
|
const onStream = req.onStream;
|
||||||
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
|
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
|
||||||
@@ -86,7 +124,23 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
|||||||
allowedTools: ["Read", "Write", "Bash", "Glob", "Grep"],
|
allowedTools: ["Read", "Write", "Bash", "Glob", "Grep"],
|
||||||
maxTurns: cap,
|
maxTurns: cap,
|
||||||
includePartialMessages: true,
|
includePartialMessages: true,
|
||||||
|
// ADR-0018: bypass interactive prompts (headless server); the sandbox
|
||||||
|
// below is the hard boundary, not the permission-prompt layer.
|
||||||
permissionMode: "bypassPermissions" as const,
|
permissionMode: "bypassPermissions" as const,
|
||||||
|
allowDangerouslySkipPermissions: true,
|
||||||
|
// ADR-0018: OS-level sandbox confines the agent process + its Bash
|
||||||
|
// subprocesses. Writes confined to the workspace; sensitive host paths
|
||||||
|
// denied reads; network open. failIfUnavailable hard-fails if the
|
||||||
|
// sandbox can't start — never run unsandboxed.
|
||||||
|
sandbox: {
|
||||||
|
enabled: true,
|
||||||
|
failIfUnavailable: true,
|
||||||
|
autoAllowBashIfSandboxed: true,
|
||||||
|
filesystem: {
|
||||||
|
allowWrite: [req.project.workspaceDir],
|
||||||
|
denyRead: [...SENSITIVE_READ_PATHS],
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
|
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
|
||||||
if (req.model !== undefined) options.model = req.model;
|
if (req.model !== undefined) options.model = req.model;
|
||||||
|
|||||||
+32
-21
@@ -9,6 +9,7 @@
|
|||||||
* provider-bound session, SDK resume continuity, ADR-0003-authorized reads.
|
* provider-bound session, SDK resume continuity, ADR-0003-authorized reads.
|
||||||
*/
|
*/
|
||||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
||||||
import type { ModelRegistry } from "../agent/models.js";
|
import type { ModelRegistry } from "../agent/models.js";
|
||||||
@@ -75,16 +76,22 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
}
|
}
|
||||||
const projectId = binding.projectId;
|
const projectId = binding.projectId;
|
||||||
// ADR-0004: triggerAgent requires edit on the project. principal=sender
|
// ADR-0004: triggerAgent requires edit on the project. principal=sender
|
||||||
// open_id (sub-typology OPEN — pragmatic choice, see permission.ts).
|
// open_id (sub-typology OPEN — pragmatic choice, see permission.ts).
|
||||||
|
// A missing open_id is treated as an untrusted event: deny explicitly
|
||||||
|
// rather than skipping the check (fail closed).
|
||||||
const senderOpenId = event.sender.sender_id.open_id ?? "";
|
const senderOpenId = event.sender.sender_id.open_id ?? "";
|
||||||
if (senderOpenId !== "") {
|
if (senderOpenId === "") {
|
||||||
const perm = await canTriggerAgent(deps.prisma, projectId, senderOpenId);
|
deps.logger.warn({ projectId, chatId }, "feishu trigger: sender has no open_id, denying");
|
||||||
if (!perm.allowed) {
|
await writeAudit(deps.prisma, { projectId, action: "trigger.denied", metadata: { reason: "missing open_id" } });
|
||||||
deps.logger.info({ projectId, senderOpenId, reason: perm.reason }, "feishu trigger: permission denied");
|
await sendText(rt, chatId, "无法识别发送者,拒绝触发。");
|
||||||
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId || undefined, action: "trigger.denied", metadata: { reason: perm.reason } });
|
return;
|
||||||
await sendText(rt, chatId, "无权限触发。");
|
}
|
||||||
return;
|
const perm = await canTriggerAgent(deps.prisma, projectId, senderOpenId);
|
||||||
}
|
if (!perm.allowed) {
|
||||||
|
deps.logger.info({ projectId, senderOpenId, reason: perm.reason }, "feishu trigger: permission denied");
|
||||||
|
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId, action: "trigger.denied", metadata: { reason: perm.reason } });
|
||||||
|
await sendText(rt, chatId, "无权限触发。");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cleanPrompt: string | null = null;
|
let cleanPrompt: string | null = null;
|
||||||
@@ -99,7 +106,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
});
|
});
|
||||||
if (project === null) return;
|
if (project === null) return;
|
||||||
try {
|
try {
|
||||||
const content = JSON.parse(msg.content) as { file_key?: string; image_key?: string };
|
const content = FileMessageContentSchema.parse(JSON.parse(msg.content));
|
||||||
const key = content.file_key ?? content.image_key ?? "";
|
const key = content.file_key ?? content.image_key ?? "";
|
||||||
if (key !== "") {
|
if (key !== "") {
|
||||||
const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`;
|
const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`;
|
||||||
@@ -178,14 +185,12 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
|
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
|
||||||
// canTriggerAgent above: that checked "can trigger an agent at all";
|
// canTriggerAgent above: that checked "can trigger an agent at all";
|
||||||
// this checks "can trigger *this* role". Unconfigured role ⇒ open.
|
// this checks "can trigger *this* role". Unconfigured role ⇒ open.
|
||||||
if (senderOpenId !== "") {
|
const rolePerm = await canTriggerRole(deps.prisma, projectId, roleId, senderOpenId);
|
||||||
const rolePerm = await canTriggerRole(deps.prisma, projectId, roleId, senderOpenId);
|
if (!rolePerm.allowed) {
|
||||||
if (!rolePerm.allowed) {
|
deps.logger.info({ projectId, roleId, senderOpenId, reason: rolePerm.reason }, "feishu trigger: role permission denied");
|
||||||
deps.logger.info({ projectId, roleId, senderOpenId, reason: rolePerm.reason }, "feishu trigger: role permission denied");
|
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId, action: "trigger.role_denied", metadata: { roleId, reason: rolePerm.reason } });
|
||||||
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId || undefined, action: "trigger.role_denied", metadata: { roleId, reason: rolePerm.reason } });
|
await sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
||||||
await sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const role = deps.models.role(roleId);
|
const role = deps.models.role(roleId);
|
||||||
const model = deps.models.resolve(undefined, roleId);
|
const model = deps.models.resolve(undefined, roleId);
|
||||||
@@ -383,6 +388,14 @@ function withFileDeliveryInstructions(systemPrompt: string | undefined): string
|
|||||||
"Do not say a file is attached or sent unless that tool returns success.";
|
"Do not say a file is attached or sent unless that tool returns success.";
|
||||||
return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`;
|
return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`;
|
||||||
}
|
}
|
||||||
|
/** Lark text-message content: `{"text":"@_user_1 do something"}`. */
|
||||||
|
const TextMessageContentSchema = z.object({ text: z.string() });
|
||||||
|
|
||||||
|
/** Lark file/image-message content: carries a file_key or image_key. */
|
||||||
|
const FileMessageContentSchema = z.object({
|
||||||
|
file_key: z.string().optional(),
|
||||||
|
image_key: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the prompt text from a text message, stripping @mentions.
|
* Extract the prompt text from a text message, stripping @mentions.
|
||||||
@@ -397,9 +410,7 @@ export function extractPrompt(msg: MessageReceiveEvent["message"]): string | nul
|
|||||||
if (mentions === undefined || mentions.length === 0) return null;
|
if (mentions === undefined || mentions.length === 0) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(msg.content) as { text?: string };
|
const text = TextMessageContentSchema.parse(JSON.parse(msg.content)).text;
|
||||||
const text = parsed.text;
|
|
||||||
if (typeof text !== "string") return null;
|
|
||||||
// Strip @mentions (@_user_1 etc.) and trim; remainder is the prompt.
|
// Strip @mentions (@_user_1 etc.) and trim; remainder is the prompt.
|
||||||
const stripped = text.replace(/@_\w+\s*/g, "").trim();
|
const stripped = text.replace(/@_\w+\s*/g, "").trim();
|
||||||
return stripped === "" ? null : stripped;
|
return stripped === "" ? null : stripped;
|
||||||
|
|||||||
@@ -60,14 +60,18 @@ describe("runAgent", () => {
|
|||||||
cwd: "/tmp/ws",
|
cwd: "/tmp/ws",
|
||||||
model: "anthropic/claude-sonnet-5",
|
model: "anthropic/claude-sonnet-5",
|
||||||
resume: "sdk-session-old",
|
resume: "sdk-session-old",
|
||||||
|
// ADR-0018: agent execution surface bounded by workspace.
|
||||||
|
permissionMode: "bypassPermissions",
|
||||||
|
allowDangerouslySkipPermissions: true,
|
||||||
|
sandbox: expect.objectContaining({
|
||||||
|
enabled: true,
|
||||||
|
failIfUnavailable: true,
|
||||||
|
filesystem: expect.objectContaining({
|
||||||
|
allowWrite: ["/tmp/ws"],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(result).toMatchObject({
|
|
||||||
status: "completed",
|
|
||||||
text: "ok",
|
|
||||||
sdkSessionId: "sdk-session-new",
|
|
||||||
usage: { inputTokens: 3, outputTokens: 5 },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send resume for a fresh Hub session", async () => {
|
it("does not send resume for a fresh Hub session", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user