feat(edge-worker): scoped environments for agent sessions (CYPACK-1130)#1157
Open
cyrusagent wants to merge 15 commits into
Open
feat(edge-worker): scoped environments for agent sessions (CYPACK-1130)#1157cyrusagent wants to merge 15 commits into
cyrusagent wants to merge 15 commits into
Conversation
Adds an `environment` config abstraction that lets a Linear issue bind its agent session to a reusable bundle of system prompt, allowed and disallowed tools, MCP config paths, sandbox filesystem permissions, plugins, and skills — stored as JSON in `~/.cyrus/environments/<name>.json`. Users trigger binding with `env=<name>` (or `[env=<name>]`) in the issue description. Once bound, the environment name persists on the session and is re-applied on every restart/resume.
…(CYPACK-1130) Environment configs now treat read-only repository access and worktree creation as orthogonal resources: - `repositories`: repo IDs whose `repositoryPath` is added to the session's `allowedDirectories` for read access. No worktree is created for them. - `gitWorktrees`: 0..N repo IDs that drive `createGitWorktree`. Omitting the field preserves the current routed-repository behavior. An empty array produces a plain no-git workspace; multiple entries use the existing multi-repo workspace path. EdgeWorker loads the environment before workspace creation so `gitWorktrees` can influence the worktree layout, then adds the env's read-only repo paths into `allowedDirectories` alongside the worktree paths. Two pure helpers (`resolveEnvironmentWorktreeRepos`, `resolveEnvironmentReadOnlyRepoPaths`) encapsulate the lookup so EdgeWorker stays free of env-shape knowledge (DIP/SRP).
EnvironmentConfig's `repositories` and `gitWorktrees` fields now reference repos by their user-visible `RepositoryConfig.name` (case-insensitive) instead of the internal `id`. This aligns with the existing `repos=` description-tag semantics (which also prefers name matches) and keeps env files stable across deployments that may regenerate internal ids. The resolvers share a private `findRepoByName` helper so the match rule lives in one place.
Adds an `env` field to EnvironmentConfig — a plain string-to-string map exposed to the Claude runner subprocess via `additionalEnv`. Merge order is deliberate: env vars declared by the environment are the base layer, and sandbox-managed variables (NODE_EXTRA_CA_CERTS, GIT_SSL_CAINFO, etc.) layer on top. An env author cannot accidentally break MITM TLS for the egress proxy by shadowing a CA cert variable.
…K-1130) Linear issue descriptions can now tune environment variables per-issue using the grammar `env=<name>$KEY=VALUE,$KEY=VALUE` (bracketed form also supported). The grammar piggybacks on the existing `env=` tag so the environment binding and its overrides are parsed together. Security posture: - EnvironmentConfig gains `allowInlineOverrides: string[]` — an explicit allowlist of keys that may be tuned inline. Any key not in the list is silently dropped and logged. If the field is omitted or empty, no inline overrides are accepted even when present in the description. - Malformed keys (lowercase, starts with digit, missing `=`) are dropped at parse time, before the allowlist check. - Accepted overrides are persisted on `CyrusAgentSession` as `environmentOverrides` so restarts reapply the exact same env. Precedence when building the runner's additionalEnv (lowest → highest): 1. Environment file `env` field (admin-declared defaults) 2. Session `environmentOverrides` (inline, issue-author declared) 3. Sandbox-managed vars (NODE_EXTRA_CA_CERTS etc. — must stay intact)
…CK-1130)
Adds `claudeSettingSources?: ("user"|"project"|"local")[]` to
EnvironmentConfig so an environment can opt out of (or restrict) the
file-based Claude settings the SDK loads into the spawned agent:
- omitted → backwards-compatible default of all three sources
- `[]` → fully isolated, no settings inherited
- subset → only the listed sources (e.g. `["project"]` ignores the
user's personal global settings)
Threads through AgentRunnerConfig → ClaudeRunnerConfig so
RunnerConfigBuilder only needs to forward the option when set; the
existing hardcoded default in ClaudeRunner is preserved when the env
field is omitted.
…ACK-1130) Adds an opt-in `isolated: boolean` to EnvironmentConfig. When true, the session is built solely from the environment config — no implicit merging with repository defaults, label-derived prompts, dynamic Linear/Slack MCP servers, default tool lists, auto-discovered skill plugins, the Stop / screenshot hooks, the Chrome extra-arg, the default file-based settings sources, attachments / git-metadata dirs in allowedDirectories, or the hardcoded sandbox filesystem rules. Two runtime-safety exceptions remain enforced regardless of mode: the worktree path is always in sandbox `allowWrite`, and egress-proxy CA-cert env vars always layer on top of `env`. Refactor: extracts a new `EnvironmentResolver` class that takes the base/default per-session inputs plus the env and returns the effective values. RunnerConfigBuilder now delegates all merge logic to the resolver — SRP for "how does an env override defaults" lives in one place. EdgeWorker queries the same `isolated` flag when building allowedDirectories. The legacy inline merge helpers are removed. Also fixes the previously-flagged sandbox wart: env-supplied `filesystem.allowRead` / `denyRead` / `allowWrite` are now respected in isolated mode (the hardcoded per-session overrides were silently dropping them before).
…130) Adds `restrictHomeDirectoryReads: boolean` to EnvironmentConfig (and threads it through AgentRunnerConfig → ClaudeRunnerConfig) so an environment can opt out of Cyrus's automatic home-directory enumeration that adds every top-level entry under `~/` (other than worktree ancestors) to `disallowedTools` as `Read(<path>/**)`. Default behavior unchanged — the safety enumeration still runs unless the env explicitly sets the flag to false. EnvironmentResolver propagates the env's choice; ClaudeRunner skips the enumeration when the resolved value is false. Even in isolated environments, the default stays on so an env author can't accidentally widen reads by omitting the field — they must opt out explicitly.
Adds `ActivityPoster.postEnvironmentBindingActivity` that surfaces the matched environment in the Linear timeline as a thought activity. The body lists only the fields the environment actually customized so the entry stays scannable: prompt source, tool list sizes, MCP path count, sandbox override, plugin/skill count, settings sources, env-variable keys, allowlist + accepted inline overrides, read-only repos, worktrees, and home-dir read restriction. The pure formatter is exposed as a static method so unit tests can assert on rendered text without mocking issue trackers (SRP). EnvironmentResolver and the resolver-driven RunnerConfigBuilder are unchanged — only the activity-posting layer is new.
…CK-1130) Fixes a long-standing bug where ClaudeRunner.canUseTool unconditionally returned `behavior: "allow"` for every non-AskUserQuestion tool. The SDK calls canUseTool for any tool not in `allowedTools` (in permissionMode "default"), so the rubber-stamp made `allowedTools` silently advisory — bound environments could still run arbitrary Bash, Glob, Write, etc. New `strictToolPermissions` flag (threaded through EnvironmentConfig → AgentRunnerConfig → ClaudeRunnerConfig): - when true: canUseTool denies any non-AskUserQuestion tool the SDK asks about, with a clear "not permitted by this environment's allowedTools" message - when unset/false: legacy rubber-stamp preserved EnvironmentResolver auto-flips it to true for any env-bound session (env can opt back out via `strictToolPermissions: false`). Sessions with no env bound see no behavior change. Also: register the canUseTool callback whenever strictToolPermissions is requested, even without an onAskUserQuestion handler — otherwise the SDK has no callback to ask and falls back to its own default allow. ActivityPoster surfaces the opt-out in the binding message so operators can see when an env intentionally relaxed enforcement.
…CK-1130) Strict tool permission enforcement was incomplete: even with canUseTool registered, the SDK only invokes the callback when `permissionMode` is one that requires permission decisions. Without explicitly setting it, the SDK could inherit "bypassPermissions" or a similar permissive mode from the user's runtime defaults and skip canUseTool entirely — letting the agent run any tool regardless of allowedTools. Now: when strictToolPermissions=true, always pass permissionMode: "default" so the SDK guarantees it routes permission decisions through canUseTool, where the strict-deny logic can actually take effect.
Adds INFO-level logs for every canUseTool call so operators can confirm whether the SDK is actually routing permission decisions through Cyrus, or bypassing the callback entirely for "safe" tools. This is diagnostic-only — no behavior change. Will be downgraded to DEBUG (or removed) once the SDK's invocation rules under strictToolPermissions + permissionMode "default" are fully characterized.
…ct mode (CYPACK-1130) Confirmed by diagnostic logging: the Claude Agent SDK silently bypasses canUseTool for several "safe" tools (Glob, Grep, Edit, Write, Bash, etc.). The previous strict-mode commit registered the callback and forced permissionMode "default", but the SDK never called the callback for these tools so allowedTools enforcement was still silently advisory. Real fix: in strict mode, auto-extend `disallowedTools` with every built-in tool name from the canonical `availableTools` list that isn't represented in `allowedTools`. The SDK actively honors `disallowedTools` even for tools that bypass canUseTool, so this is the only reliable enforcement layer. Tool-name extraction strips trailing patterns (`Bash(grep *)` → `Bash`), so a single allowed-tool entry covers the whole tool name without disabling the SDK's own pattern matching for that tool. AskUserQuestion is always preserved because Cyrus uses it for the agentic permission flow. Diagnostic INFO logs added in 50466c4 are preserved — they remain useful for confirming behavior across future SDK versions.
…(CYPACK-1134) (#1159) * feat(edge-worker): inject email-synced Linear comment replies into active agent sessions (CYPACK-1134) When Linear mirrors a comment thread to an email chain, replies in that thread are now injected into the most recently created agent session on the issue. Detection works by walking a new comment's ancestry and matching Linear's auto-generated root comment body. - New isCommentCreateWebhook type guard + CommentCreateWebhook type - EdgeWorker.handleCommentCreatedWebhook routes matching replies through the existing handlePromptWithStreamingCheck path - Filters out comments authored by the Cyrus OAuth app user (default 2902b10a-8454-4907-8670-4767dab347ff@oauthapp.linear.app, overridable via CYRUS_LINEAR_BOT_EMAIL) to prevent feedback loops * chore(changelog): link PR #1159 for CYPACK-1134 * refactor(edge-worker): rename mostRecent to mostRecentSession in email-synced comment handler * refactor(core): consolidate Linear thread marker constants in cyrus-core * refactor(edge-worker): simplify email-synced thread detection, Linear threads are flat * chore(edge-worker): remove hardcoded CYRUS_LINEAR_BOT_EMAIL fallback; disable handler when unset * feat(edge-worker): distinguish internal vs email commenters + post ack thought - PromptAssemblyInput gets a new optional commentSource ("linear" | "email") which is threaded through buildSessionPrompt, resumeAgentSession, and handlePromptWithStreamingCheck so it lands in the <new_comment>/<user_comment> XML as a <source> element. Also wrap the mid-stream injection case in the same XML when author/timestamp/source metadata is present. - handleCommentCreatedWebhook derives commentSource from the webhook payload (comment.user populated = internal Linear user; null + externalUser = email reply) and picks the display name from the appropriate field. - Before injection, post a thought activity to the Linear agent session so the timeline shows that an email-synced reply was picked up (parity with the prompted acknowledgment).
# Conflicts: # CHANGELOG.md # packages/claude-runner/src/types.ts # packages/edge-worker/src/RunnerConfigBuilder.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds an environment config abstraction for agent sessions. Users bind via
env=<name>or[env=<name>]in issue descriptions. Environments are JSON files in ~/.cyrus/environments/.json with fields: systemPrompt/systemPromptPath, allowedTools, disallowedTools, mcpConfigPath, sandbox.filesystem.{allowRead,denyRead,allowWrite,denyWrite}, plugins, skills. Binding persists on CyrusAgentSession and re-applies on restart/resume. PromptBuilder self-describes the new selector in <repository_routing_context>. Ref: CYPACK-1130. 731 tests pass (13 new loader tests + 8 new parser tests).