Skip to content

feat(server): add permission-gated agent action audit API#9735

Open
cryppadotta wants to merge 7 commits into
paperclipai:masterfrom
cryppadotta:feat/agent-action-audit-api
Open

feat(server): add permission-gated agent action audit API#9735
cryppadotta wants to merge 7 commits into
paperclipai:masterfrom
cryppadotta:feat/agent-action-audit-api

Conversation

@cryppadotta

@cryppadotta cryppadotta commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Thinking Path

  • Paperclip is the open source control plane for managing AI-agent companies.
  • Company activity logs already capture agent-attributed mutations and responsible-user context.
  • Operators need one company-scoped feed for reviewing those actions without issuing N+1 entity lookups.
  • Full-company audit visibility is security-sensitive and must not be available to agents by default.
  • Existing activity-log sanitization and responsible-user attribution should remain the source of truth.
  • This pull request adds a permission-gated, cursor-paginated agent action audit API with bulk entity enrichment.
  • The benefit is a render-ready audit feed that preserves company boundaries, redaction, and legacy attribution.

Linked Issues or Issue Description

Subsystem affected

server/ — REST API and orchestration services, plus the shared permission-key contract.

Problem or motivation

Board operators cannot retrieve a unified feed of agent actions with responsible-user attribution and entity context. Existing activity reads lack the dedicated permission boundary, cursor pagination, filter set, and render-ready comment/issue/document snippets needed by audit views.

Proposed solution

Add GET /api/companies/:companyId/audit/agent-actions, gated by audit:view_agent_actions, with stable cursor pagination, agent/responsible-user/run/entity/action/time/actor filters, read-time redaction, heartbeat-run fallback attribution, and bulk entity enrichment.

Alternatives considered

Extending the broad company activity endpoint would preserve overly broad authorization semantics and still require UI-side N+1 fetches. A separate audit route makes the sensitive permission boundary explicit.

Roadmap alignment

Supports the roadmap direction toward durable audit trails and explicit review workflows; no duplicate open pull request was found.

Additional context

The response is designed for a board audit UI that needs to render entries such as an agent commenting on an issue without issuing follow-up entity requests.

What Changed

  • Added the audit:view_agent_actions permission key.
  • Added a stable (createdAt,id) cursor-paginated audit query with all requested filters.
  • Added read-time responsible-user fallback through heartbeat_runs for legacy rows.
  • Added bulk issue, comment excerpt, and issue-document key enrichment without N+1 queries.
  • Reused activity-log sanitization and username redaction on returned details.
  • Added embedded-Postgres route tests for denial, explicit grants, pagination, filters, enrichment, and fallback attribution.

Verification

  • pnpm --filter @paperclipai/server typecheck
  • pnpm exec vitest run server/src/__tests__/agent-action-audit-routes.test.ts

Risks

  • The endpoint exposes company-wide agent audit data, so authorization mistakes would be high impact; the route rejects agent actors and requires an explicit board permission except for trusted local/instance-admin contexts.
  • Entity snippets are intentionally nullable for deleted or unsupported entity types.
  • No database migration is included; the query uses indexes added with responsible-user attribution work and falls back through the existing run relation.

For core feature work, check ROADMAP.md first and discuss it in #dev before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See CONTRIBUTING.md.

Model Used

  • OpenAI Codex using GPT-5.4 with tool use, repository editing, command execution, and medium reasoning. Context window size was not exposed by the runtime.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have not referenced internal/instance-local Paperclip issues or links (only public GitHub #NNN / github.com/paperclipai/paperclip URLs)
  • My branch name describes the change (e.g. docs/..., fix/...) and contains no internal Paperclip ticket id or instance-derived details
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@commitperclip

commitperclip Bot commented Jul 17, 2026

Copy link
Copy Markdown

✅ All checks passing — ready for Greptile review and maintainer approval.

— commitperclip

@cryppadotta

Copy link
Copy Markdown
Contributor Author

@greptile review

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@cryppadotta

cryppadotta commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Security finding: information disclosure via audit entity enrichment in server/src/services/agent-action-audit.ts lines 87 to 126.

The enrichment pass keys entirely on entityId and never applies the hidden issue visibility gate before returning issue, comment, or document snippets. I reproduced two cases against the PR head in a scratch worktree.

  1. A normal issue.comment.created row for a hidden issue returns the hidden issue identifier and title plus the hidden comment excerpt from GET /companies/:companyId/audit/agent-actions.
  2. Even an unrelated row such as entityType=company with entityId set to an issue comment id still comes back enriched with that comment excerpt and parent issue snippet, because the lookup ignores entityType and binds only on the raw id.

Blast radius: any board principal with audit:view_agent_actions can recover hidden issue or comment metadata from same-company audit rows, and the feed can misattribute unrelated rows to issue, comment, or document snippets.

Concrete fix:

  • Gate enrichment by both entityType and the expected id form.
  • Apply the same visible issue predicate used elsewhere so hidden or harness issues produce null enrichment instead of titles or excerpts.
  • Add regression tests for a hidden issue comment row and for an entityType and entityId mismatch row.

I checked the other scoped areas during review. Company scoping, explicit board permission gating, cursor decoding, redaction reuse, and legacy responsible-user fallback looked consistent with the surrounding route and service patterns.

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a permission-gated GET /api/companies/:companyId/audit/agent-actions endpoint with stable cursor pagination, bulk entity enrichment, and read-time redaction for agent-attributed activity log entries. It also refactors the shared activity-log service to expose a reusable redactor factory, fixing a previously identified N+1 instance-settings query pattern.

  • New agentActionAuditService queries the activity log with a (createdAt DESC, id DESC) cursor using to_char(...US) for microsecond precision, validates the cursor's id as a UUID via Zod, and resolves responsible-user attribution through a LEFT JOIN on heartbeat_runs — all concerns from prior review rounds are addressed.
  • The route uses safeParse + typed badRequest/forbidden errors (no string-matched catches), and gates access behind an explicit audit:view_agent_actions permission that agents cannot satisfy.
  • Embedded-Postgres tests cover denial, sub-millisecond cursor precision, all filter combinations, hidden-issue suppression, entity enrichment, and heartbeat-run fallback attribution.

Confidence Score: 5/5

Safe to merge — all previously flagged issues (N+1 settings queries, cursor precision loss, cursor UUID validation, ZodError 500 responses) are resolved in this version, and the permission gate correctly rejects agent actors and unpermissioned board users.

The new audit route is correctly gated, uses typed errors throughout, and the cursor encoding now preserves microsecond timestamps so pagination cannot silently drop rows. The bulk enrichment queries are clean — one settings fetch per request, three bounded IN queries per page — and the comprehensive test suite exercises the key correctness and security paths end-to-end.

No files require special attention; the logic in agent-action-audit.ts and the refactored activity-log.ts are consistent with each other and with the existing codebase patterns.

Important Files Changed

Filename Overview
server/src/services/agent-action-audit.ts New service implementing cursor-paginated audit query with bulk enrichment, sub-millisecond cursor precision via to_char(..,US), UUID validation in decodeCursor, and one-time settings fetch for details redaction — all previously flagged issues appear addressed.
server/src/routes/activity.ts New GET route with explicit permission gate, safeParse for query validation returning 400 on bad input, and throws typed forbidden/badRequest errors instead of string-matched catches.
server/src/services/activity-log.ts Extracts createActivityDetailsRedactor (fetches instance settings once, returns a reusable redactor function) and redactActivityDetails helpers, refactoring logActivity to use them — fixes the N+1 instance settings issue flagged on the audit endpoint.
server/src/tests/agent-action-audit-routes.test.ts Comprehensive embedded-Postgres route tests covering denial, explicit grants, cursor pagination, sub-millisecond precision, all filter combinations, entity enrichment, hidden-issue suppression, and heartbeat-run fallback attribution.
packages/shared/src/constants.ts Adds audit:view_agent_actions permission key to the shared PERMISSION_KEYS array.
server/src/routes/openapi.ts Registers the new audit endpoint in the OpenAPI registry with accurate parameter schema, 400/403 response codes, and tags.

Reviews (6): Last reviewed commit: "fix(server): preserve audit cursor preci..." | Re-trigger Greptile

Comment thread server/src/services/agent-action-audit.ts Outdated
Comment thread server/src/routes/activity.ts Outdated
@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a permission-gated GET /api/companies/:companyId/audit/agent-actions endpoint for retrieving a cursor-paginated feed of agent-attributed activity log entries, enriched with issue/comment/document snippets and heartbeat-run fallback attribution for responsibleUserId. The authorization layer correctly rejects agent actors and requires an explicit audit:view_agent_actions board permission (bypassed only for local_implicit and instance-admin sources).

  • New audit service (agent-action-audit.ts): stable (createdAt, id) cursor pagination, bulk entity enrichment in 3 queries (no N+1 per entity), starts_with action prefix filter, and left-join on heartbeatRuns for legacy attribution fallback.
  • Shared redaction helper (activity-log.ts): redactActivityDetails extracted from logActivity and reused by the audit service; the refactor is semantically equivalent.
  • Tests: embedded-Postgres suite covering denial, grant, pagination, all nine filter cases, entity enrichment, and fallback attribution.

Confidence Score: 3/5

The authorization boundary and pagination logic look correct; the main concern is a per-row database call to the instance settings table that fires on every audit request with populated details fields.

The redactActivityDetails call inside Promise.all issues a fresh SELECT against instanceSettings for each row with non-null details — instanceSettingsService(db).getGeneral() has no caching and hits the database on every invocation. With up to 200 rows per page this creates a burst of identical queries on every audit request. This should be fixed before the endpoint sees production load.

server/src/services/agent-action-audit.ts — the Promise.all / redactActivityDetails interaction and the responsibleUserId COALESCE filter both warrant a close look.

Important Files Changed

Filename Overview
server/src/services/agent-action-audit.ts New audit service with stable cursor pagination and bulk enrichment; N+1 instanceSettings DB queries fired inside Promise.all (one SELECT per row with non-null details), and responsibleUserId filter on COALESCE prevents index usage.
server/src/routes/activity.ts New audit route correctly gates access behind assertBoard + company access + explicit permission grant; ZodError from agentActionAuditQuerySchema.parse is not caught and may surface as 500 instead of 400.
server/src/services/activity-log.ts Clean refactor extracting redactActivityDetails as a shared function; semantically equivalent to the original inline logic.
packages/shared/src/constants.ts Adds audit:view_agent_actions to PERMISSION_KEYS; new namespace prefix is intentional per the PR description.
server/src/tests/agent-action-audit-routes.test.ts Comprehensive embedded-Postgres test suite covering denial, explicit grant, pagination, all filter cases, entity enrichment, and heartbeat-run fallback attribution.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
server/src/services/agent-action-audit.ts:111-129
**N+1 DB queries for instance settings per page item**

`redactActivityDetails` calls `instanceSettingsService(db).getGeneral()` which issues a raw `SELECT` against the `instanceSettings` table on every invocation — there is no caching in that service. Inside `Promise.all`, this fires up to N concurrent identical SELECT queries (one per row with non-null `details`) for the same singleton row on every audit request. Fetch the setting once before the map and pass it inline.

```suggestion
      const { censorUsernameInLogs } = await instanceSettingsService(db).getGeneral();
      const items = page.map((row) => {
        const comment = comments.get(row.entityId);
        const issue = issueMap.get(row.entityId);
        const document = documents.get(row.entityId);
        const issueSnippet = comment
          ? { id: comment.issueId, identifier: comment.identifier, title: comment.title }
          : document
            ? { id: document.issueId, identifier: document.identifier, title: document.title }
            : issue ? { id: issue.id, identifier: issue.identifier, title: issue.title } : null;
        const redactedDetails = row.details
          ? redactCurrentUserValue(sanitizeRecord(row.details), { enabled: censorUsernameInLogs })
          : null;
        return {
          ...row,
          details: redactedDetails,
          entity: {
            issue: issueSnippet,
            comment: comment ? { id: comment.id, excerpt: excerpt(comment.body) } : null,
            document: document ? { id: document.documentId, key: document.key } : null,
          },
        };
      });
```

### Issue 2 of 3
server/src/routes/activity.ts:119
**ZodError from query parsing returns 500, not 400**

`agentActionAuditQuerySchema.parse(req.query)` sits outside the `try/catch` block, so any `ZodError` (e.g., a non-integer `limit` or an invalid UUID for `agentId`) propagates uncaught to the global error handler. If that handler doesn't recognise `ZodError`, the caller gets a 500 instead of a 400. The existing `POST` route avoids this by using the `validate(schema)` middleware — the same approach should be applied here, or the `parse` call moved inside the `try/catch`.

### Issue 3 of 3
server/src/services/agent-action-audit.ts:52
**`responsibleUserId` filter on COALESCE expression prevents index use**

`eq(effectiveResponsibleUserId, filters.responsibleUserId)` puts a computed SQL expression in the `WHERE` clause. PostgreSQL cannot use a plain B-tree index on `activity_log.responsible_user_id` to satisfy this filter and will fall back to a sequential scan over all agent-attributed rows for the company. On large tables this query can be slow. Consider filtering directly on `activityLog.responsibleUserId` (and accepting that legacy rows without a stored value are excluded from this filter path), or adding a partial functional index if fallback attribution is required.

Reviews (2): Last reviewed commit: "chore: retrigger PR metadata review" | Re-trigger Greptile

Comment thread server/src/services/agent-action-audit.ts Outdated
Comment thread server/src/routes/activity.ts Outdated
Comment thread server/src/services/agent-action-audit.ts Outdated
Co-Authored-By: Paperclip <noreply@paperclip.ing>

@superagent-security superagent-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superagent found 1 security concern(s).

Comment thread server/src/services/agent-action-audit.ts
@superagent-security superagent-security Bot added the pr:flagged Superagent flagged for security review label Jul 17, 2026
Co-Authored-By: Paperclip <noreply@paperclip.ing>
@cryppadotta

Copy link
Copy Markdown
Contributor Author

Addressed the security finding in cd96a89250:

  • Enrichment is now keyed by explicit issue, issue_comment, and issue_document entity types.
  • Issue/comment/document lookups apply the canonical visible-issue predicate, excluding hidden and harness issues.
  • Added regressions for hidden issue comments and entity-type/ID mismatches.
  • Also replaced string-matched invalid-cursor handling with a typed 400 error.

Focused verification passes: server typecheck and 5/5 audit-route tests.

@superagent-security superagent-security Bot removed the pr:flagged Superagent flagged for security review label Jul 17, 2026
@cryppadotta

Copy link
Copy Markdown
Contributor Author

Security review on head cd96a89250bed19464c12d66fe101ab6311339d8 is complete.

I re-checked the scoped risk areas after the fix(server): prevent audit enrichment disclosure follow-up:

  • Broken Access Control / company boundary: route stays board-only, calls assertCompanyAccess, and requires explicit audit:view_agent_actions outside local_implicit and instance-admin contexts.
  • Information disclosure / hidden+harness visibility: enrichment now keys strictly by entityType and uses the canonical visible-issue predicate, so hidden or harness-backed issues/comments/documents come back as null enrichment instead of leaking titles or excerpts.
  • Read-time redaction: the endpoint reuses the shared activity-details redactor, so stored details remain sanitized and current-user placeholders stay censored at read time.
  • Legacy responsible-user fallback: fallback stays company-scoped through the (company_id, run_id) join to heartbeat_runs; I did not find a cross-company attribution path.
  • Cursor/filter validation: invalid query input now fails closed with typed 400s instead of relying on brittle string matching.

I did not find a remaining cross-company or hidden-issue disclosure on this head.

Current merge readiness from a security perspective:

  • Security blocker from the earlier enrichment leak appears fixed on cd96a89250.
  • Focused regression verification passed: pnpm exec vitest run --hookTimeout 30000 server/src/__tests__/agent-action-audit-routes.test.ts.
  • pnpm --filter @paperclipai/server typecheck was not reproducible in the disposable review worktree because the worktree lacked the repo's normal pnpm dependency layout; that failure did not point to a code error in this PR.
  • Latest-head GitHub Actions / Greptile status is recorded separately below; if Greptile or CI is still pending, I would treat the PR as not yet ready to merge operationally, even though I do not currently have an open security finding against this head.

Residual risk:

  • This endpoint intentionally returns company-wide agent audit data to permitted board principals, so the main residual risk remains future regressions in the visibility predicate or permission gate. The new hidden-issue and entity-type mismatch tests materially reduce that risk.

@cryppadotta

Copy link
Copy Markdown
Contributor Author

Security finding: information disclosure via hidden-issue details on GET /api/companies/:companyId/audit/agent-actions.

The entity-enrichment fix in server/src/services/agent-action-audit.ts closes the raw entityId leak, but the endpoint still returns details unchanged apart from username redaction (createActivityDetailsRedactor at lines 144-163 in the current PR head). Several issue-scoped activity producers log hidden-issue metadata directly into details:

  • server/src/routes/issues.ts:8396-8411 logs commentId, bodySnippet, identifier, and issueTitle for issue.comment_added.
  • server/src/routes/issues.ts:6057-6079 logs key, documentId, title, and reference context for issue.document_created / issue.document_updated.
  • server/src/services/plugin-host-services.ts:2020-2031 logs identifier, commentId, and bodySnippet for plugin-authored issue.comment.created rows.

Attack / code path:

  1. Create or update a hidden or harness issue comment/document through any of the above paths.
  2. Fetch GET /companies/:companyId/audit/agent-actions as a board principal with audit:view_agent_actions.
  3. Even when visibleIssueCondition() nulls the returned entity snippets, the response still carries the hidden issue's details.bodySnippet, details.issueTitle, details.title, or details.identifier.

Blast radius: any same-company board user with audit:view_agent_actions can recover hidden or harness issue/comment/document metadata from agent-attributed audit rows. This is still an information disclosure across the intended hidden/harness visibility boundary.

Concrete fix:

  • Add an audit-time visibility scrub for issue-derived details fields when the underlying issue fails visibleIssueCondition().
  • Centralize that scrub in the audit service rather than trying to patch each producer individually.
  • Add regressions for hidden issue comment/document rows with populated details, asserting both entity and sensitive details fields are null/omitted.

As of July 17, 2026 at 02:30 UTC, the latest PR head is still not review-ready anyway: Verify serialized server suites (2/4) has failed, General tests (server (3/3)) and e2e are still in progress, and the fresh Greptile Review check for cd96a89250bed19464c12d66fe101ab6311339d8 is still in progress.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@cryppadotta

Copy link
Copy Markdown
Contributor Author

Security follow-up

Fixed the remaining hidden-issue details disclosure at head 0d4979ba44.

  • Issue-derived audit rows now return details: null when their underlying issue cannot resolve through the canonical visible-issue predicate.
  • This centrally protects comment/document snippets and future producer detail keys without patching each activity producer.
  • Added regressions for hidden comment and hidden document details.
  • Verified the focused audit route suite (5 tests) and server-only typecheck.

A fresh SecurityEngineer re-review is queued on the exact current head.

@cryppadotta

Copy link
Copy Markdown
Contributor Author

@greptile review

Comment thread server/src/services/agent-action-audit.ts
Comment thread server/src/services/agent-action-audit.ts Outdated
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