Skip to content

feat(board): attach files to a ticket — the agent finds them in its working folder - #290

Merged
hoangsnowy merged 2 commits into
mainfrom
feat/ticket-files
Oct 24, 2026
Merged

hoangsnowy merged 2 commits into
mainfrom
feat/ticket-files

Conversation

@hoangsnowy

@hoangsnowy hoangsnowy commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Change description

Ticket files — the only way files reach an agent (ADR-0015). An agent working a ticket on a member's computer can't download anything (scoped autonomy denies WebFetch and has no internet curl), so a person had no way to hand it a logo, art, a sample data file or a third-party skill folder to vendor — VNChess #108 failed on exactly that. Now a person attaches files to the ticket in the desktop (or over REST), AgentOS.NET stores them per tenant, and every agent run of the ticket finds them in its working folder.

Data model — sessions.ticket_files (Sessions module, migration AddTicketFiles): Id, TenantId, ScopeKey, Path, PathKey, Content (bytea), Size, Sha256, ContentType, UploadedBy, UploadedAtUtc, unique (TenantId, ScopeKey, PathKey) — PathKey is the path in invariant lower case, so Logo.png and LOGO.PNG are one file at the database too (a re-upload replaces; the service refuses a capitals-only twin in words). Tenant isolation: EF query filter + explicit tenant predicates in every store call + RLS (RowLevelSecurity.Tables, context already has the interceptor). The store is generic — IScopedFileStore Put / List / Get / LoadAll / Delete / DeleteScope by tenant + scope key — so the design-gate screenshots can reuse it. Scopes: ticket:{board}:{n}, session:{id} (task without a number), draft:{id} (New idea before its tickets exist — copied onto every ticket created from it). No database (standalone Development) → an in-memory store (bounded to 512 MB), so the feature works for real there.

Rules — TicketFilePath (Domain, BCL-only, linked into the runner): relative, /-separated, no ./.., no leading / or ~, no : (drives, NTFS streams), no < > " | ? * / control chars, no Windows device names, no segment ending in dot/space, ≤ 200 chars. ITicketFiles adds the limits, refuses names differing only in capitals, records SHA-256. Any file type allowed, never executed (REST downloads are always application/octet-stream attachments).

Limits (UI settings, ADR-0011, Settings › Autopilot › Files for the AI): TicketFiles:MaxFileMegabytes 10 (1–100), TicketFiles:MaxTotalMegabytes 50 (1–500), TicketFiles:MaxFiles 500 (1–2000). A run carries at most 512 MB regardless.

Delivery path

  • BoardTaskService loads the task's files for every fresh run, follow-up, review and test, and puts them on IssueWorkRequest.Files / AcceptanceRequest.Files (feed: "Sending the N file(s) attached to this ticket…").
  • On a member's computer: LlmRequest.Files → RemoteAgentLlmClient sends a manifest in RemoteExecRequest.Files (path, size, SHA-256) and stages the bytes in the broker. The runner pulls each file in 256 KB pieces via the new ReadRunFile hub method — answered only for the connection the run was dispatched to — and RunFiles.WriteAllAsync writes <run dir>/.agentos/files/<path> before the CLI starts, re-checking the path rules, staying inside the folder, never overwriting, verifying size + SHA-256 (512 MB cap). Any refusal fails the run before the CLI starts. Bytes are released once fetched.
  • Server-driven (runner_shell): IRunnerFileDelivery sends one write_files tool call ahead of the loop (same approval gate); the runner writes into a fresh <work root>/<date>-files-<id>/ (cleaned after 3 days) and returns the path the prompt then names. Delivery failure fails the repo with a plain reason.
  • Prompt: one "Files attached to this ticket" section in both IssueWork modes, Reviewer and Testing — only when there are files and they reach the computer; file names fenced as untrusted data; "clone into a subfolder" (the run folder is no longer empty); copy only with the runner's helper sh .agentos/bin/copy-in .agentos/files/<path> repo/<destination> (see Security below; WebFetch stays denied). Server-driven runs keep cp from the delivery folder — their commands pass the runner's own command policy + console approval, not the CLI's scoped rules.
  • Evidence: one ticket_files entry per delivery (run id, session, each file's path/size/SHA-256, "Delivered N of M"), plus a feed line. Not an LLM call — Insights unaffected.

Security (review of this PR, fixed in the second commit)

  • No cp rule in scoped autonomy. A Claude Code Bash(...) rule is a prefix match and acceptEdits confines only the edit tools, so Bash(cp .agentos/files/*) would have let cp .agentos/files/x ~/.ssh/authorized_keys through. The only new rule is Bash(sh .agentos/bin/copy-in *) (+ the PowerShell twin). The runner writes that script on every delivery from a constant (RunFiles.CopyHelperScript), never from server/ticket content. It copies one regular, non-link file from .agentos/files/ to a destination resolved with cd -P inside the run folder and not under .agentos/. It refuses ./.. segments, a link as the destination or any folder above it, and overwriting an existing file outside the cloned repository. It makes missing folders inside the run folder only. A regression test fails the build if a Bash(cp / PowerShell(cp rule reappears.
  • Runner writes: create-exclusive (FileMode.CreateNew), and the run is refused when any folder between .agentos and the file is a reparse point (symlink/junction), checked before and after the folders are created.
  • Case-insensitive uniqueness at the database (PathKey), the in-memory store keyed the same way.
  • Board-level access: ticket files are tenant-scoped exactly like every other Board endpoint and screen — any member of the tenant may list, add, download or remove the files of any board's ticket in that tenant; there is no per-board ACL in AgentOS.NET today (ADR-0013 proposes teams/projects). Cross-tenant access is impossible (tenant from the token / circuit, explicit predicates, EF filter, RLS).

Surfaces — TicketFilesPanel ("Files for the AI", plain-language copy) on Board › Tasks (per-task Files toggle), the Work on this dialog, and both New idea steps: pick files (multiple), drop them, or Add a folder (webkitdirectory, relative paths kept via a 6-line JS helper); list with path / size / uploader; remove. Real file inputs laid over their controls → Tab + Enter/Space work, focus ring on :focus-within, prefers-reduced-motion respected, tokens only (new .ticket-files-* rules in app.css). REST: GET|PUT|DELETE /workspaces/{boardId}/tickets/{n}/files[/{**path}] (Member policy, tenant from the token; upload is a raw-body PUT — scriptable with curl -T, and no form CSRF on the cookie host).

Claude Design push: pending — the new Files for the AI component and the Autopilot settings panel were built in the repo with the existing vocabulary (Btn / Badge / Icon / .data-table, tokens); they still need to be pushed up to the AgentOS.NET Design System project (DesignSync auth isn't available in this non-interactive session).

Not done (out of scope / noted in the ADR): .zip expansion (folder upload covers it without zip-slip risk); cleanup of New idea drafts abandoned without Start another; concurrent uploads to one ticket can overshoot a limit by one file; the Thorough (server pipeline) engine doesn't use ticket files (its feed says so); a runner older than this change ignores the manifest (evidence says "Delivered 0 of N").

Type of change

  • New feature (new agent / endpoint / capability)
  • Bugfix
  • Refactor (no behavior change)
  • Documentation / README
  • Infrastructure / CI configuration

Related issues

VNChess #108 (the agent couldn't fetch the design skills it was asked to vendor).

Checklist

  • dotnet build passes locally in Release mode
  • dotnet test passes (with coverage if available) — dotnet test --solution AgentOs.slnx -c Release: 1939 total, 0 failed (after the security fixes)
  • No secrets committed (API keys, connection strings)
  • README / docs updated if public behavior changed — README (Board), docs/CONFIG.md (the three limits), ADR-0015 + index
  • Measurable results provided when related to agent output quality — n/a (delivery is evidence; no LLM calls added)

Test plan

  • Unit (AgentOs.Tests): path normalisation + rejection (.., absolute, UNC, drive, C:x, ~, ADS colon, reserved names, control chars, too long); limits from settings (defaults, per tenant, range); SHA-256; replace; capitals clash; two-tenant isolation (in-memory store and EF store on EF InMemory); draft → ticket copy; runner-side write in a real temp dir (nested paths, chunked, traversal / absolute refused with nothing written outside, SHA / size mismatch removes the partial file, no overwrite, cap, delivery-folder cleanup); broker serves staged files only to the dispatched connection, in pieces, releases them; RemoteAgentLlmClient sends the manifest + records evidence (and "Delivered 0 of 1" for an old runner); write_files path; dispatch contract round-trip through SignalR's JsonHubProtocol into the runner's own shapes (Execute + Files, the byte chunk, the tool input); prompt section present only with files that reach the computer (Coding CLI + server mode, Reviewer, Testing), fence can't be closed by a file name, 60-file cap; scoped-autonomy copy rules; RLS table list.
  • Real Postgres (TicketFilesRealPostgresTests, gated by RUN_AGENTOS_E2E_REAL + AGENTOS_PG_CONN): run against the local Aspire Postgres — creates a throwaway database, applies the Sessions migrations, exercises the store with two tenants, bootstraps RLS and proves the policy binds sessions.ticket_files (no-predicate query sees only tenant A; writing tenant B's row refused, 42501); proves the case-insensitive unique index (LOGO.PNG twin row refused, 23505); drops the database (residue query: 0).
  • copy-in, run for real with sh (Git Bash on Windows, sh on Linux CI; skipped when none): happy path into the clone (folders made, a file in the clone may be replaced), ../ escape, .. in the source, absolute destination outside, a symlinked destination folder, a symlinked source, a source outside .agentos/files, a destination in .agentos, overwriting a file outside the clone, a folder as destination. Plus a linked folder under the files root refused by the runner, and Logo.png then LOGO.PNG as one file in both stores.
  • Standalone E2E (TicketFilesTests, RUN_AGENTOS_E2E=true AGENTOS_URL=http://localhost:5199): Board › New idea → upload two files, a folder (keeps design-skill/assets/tokens.json), refused con.txt in plain words, keyboard focus on the picker, remove all (zero residue); Settings › Autopilot shows the three limits with their defaults. Full standalone E2E suite: 57 total, 0 failed. Screenshots taken in light + dark.
  • Not verified here: the full Aspire stack in the desktop with two signed-in tenants, and a live run on a paired computer (lab claude -p) writing the files into a real run folder — the runner path is covered by the temp-dir tests and the protocol round-trip, not by a live lab run.

🤖 Generated with Claude Code

https://claude.ai/code/session_018WAdQ7Rm24xss5QDSvXtJB

…orking folder

An agent on a member's computer can't download anything (scoped autonomy denies it), so a person had no way to hand
it a logo, art, sample data or a skill folder to vendor (VNChess #108 failed on exactly that).

- Store: sessions.ticket_files (tenant-scoped, RLS, EF filter + explicit predicates), generic IScopedFileStore keyed by
  tenant + scope key (ticket / task / New idea draft); in-memory store when there is no database. ITicketFiles applies
  the path rules (TicketFilePath: no .., absolute, drive, colon, reserved names; <= 200 chars), the tenant's limits
  (TicketFiles:* settings, Settings > Autopilot) and records SHA-256.
- Delivery: the dispatch carries a manifest; the runner pulls each file in 256 KB pieces (ReadRunFile, only the
  dispatched connection) and writes <run dir>/.agentos/files/<path> before the CLI starts, re-checking path, size and
  SHA-256. Server-driven runs get a write_files tool call first. Every delivery is evidence (ticket_files).
- Prompts: Coding (both IssueWork modes), Reviewer and Testing get one section listing the files as untrusted data,
  only when they reach the computer; scoped autonomy allows cp from .agentos/files.
- Desktop: "Files for the AI" on Board > Tasks, the Work dialog and both New idea steps (files, drop, folder);
  REST GET/PUT/DELETE /workspaces/{board}/tickets/{n}/files.
- ADR-0015, README, CONFIG; unit, real-Postgres and standalone E2E tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018WAdQ7Rm24xss5QDSvXtJB
// The ticket files of one dispatch. A file's bytes are released as soon as its runner has fetched the last piece.
private sealed class StagedFiles(System.Collections.Generic.IReadOnlyList<AgentOs.Domain.Files.RunFile> files)
{
public readonly byte[]?[] Contents = files.Select(f => (byte[]?)f.Content).ToArray();
Comment on lines +58 to +61
foreach (var f in Scope(tenantId, scopeKey).ToList())
{
if (await DeleteAsync(tenantId, scopeKey, f.Info.Path, ct).ConfigureAwait(false)) { count++; }
}
Comment on lines +122 to +125
foreach (var dir in Directory.EnumerateDirectories(root, "*-files-*"))
{
if (DateTime.UtcNow - Directory.GetCreationTimeUtc(dir) > age) { ScopedAutonomy.TryDelete(dir); }
}

public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { } catch (UnauthorizedAccessException) { }

public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { } catch (UnauthorizedAccessException) { }
…n helper

Security review of #290:
- Scoped autonomy no longer allows any "cp" rule. A Bash rule is a prefix match and acceptEdits confines only the
  edit tools, so "cp .agentos/files/x ~/.ssh/authorized_keys" would have passed. The runner now writes a constant
  helper, .agentos/bin/copy-in, on every delivery; the only new rule is Bash(sh .agentos/bin/copy-in *) (+ PowerShell
  twin). The helper copies one regular, non-link file from .agentos/files to a destination resolved inside the run
  folder (not .agentos), refuses . / .. segments, links on the destination path, and overwriting a file outside the
  clone; it makes folders inside the run folder only. The Coding prompt tells the agent to use it.
- The runner refuses a delivery when a folder between .agentos and the file is a reparse point (checked before and
  after creating folders); files are written create-exclusive.
- Uniqueness is case-insensitive at the database: PathKey (invariant lower case) with the unique index on
  (TenantId, ScopeKey, PathKey); the in-memory store is keyed the same. AddTicketFiles regenerated (never applied to a
  shared database).
- Tests: copy-in run for real with sh (happy path, ../ escape, absolute destination outside, linked destination
  folder, linked or out-of-folder source, .agentos, overwrite outside the clone); a regression test that no cp rule
  exists; a linked folder under the files root; Logo.png then LOGO.PNG in both stores and on real Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018WAdQ7Rm24xss5QDSvXtJB
@hoangsnowy
hoangsnowy merged commit 28b05ed into main Oct 24, 2026
4 checks passed
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.

2 participants