Skip to content

feat(edge-worker): scoped environments for agent sessions (CYPACK-1130)#1157

Open
cyrusagent wants to merge 15 commits into
mainfrom
cypack-1130
Open

feat(edge-worker): scoped environments for agent sessions (CYPACK-1130)#1157
cyrusagent wants to merge 15 commits into
mainfrom
cypack-1130

Conversation

@cyrusagent
Copy link
Copy Markdown
Contributor

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).

cyrusagent and others added 15 commits April 23, 2026 16:45
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant