Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions apps/server/src/audit/audit-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export type AuditAction =
| "task.execution"
| "patch.proposal"
| "approval.decision"
| "sensitive.action";

export type AuditActorType = "user" | "agent" | "system";
export type AuditApprovalDecision = "approved" | "rejected" | "notRequired" | "pending";

export interface AuditActor {
readonly type: AuditActorType;
readonly id: string;
readonly displayName?: string;
}

export type AuditMetadataValue =
| string
| number
| boolean
| null
| readonly AuditMetadataValue[]
| { readonly [key: string]: AuditMetadataValue };

export type AuditMetadata = Readonly<Record<string, AuditMetadataValue>>;

export interface AuditEntry {
readonly id: string;
readonly action: AuditAction;
readonly actor: AuditActor;
readonly taskId?: string;
readonly agentId?: string;
readonly runtimeId?: string;
readonly patchProposalId?: string;
readonly commandSummary?: string;
readonly diffSummary?: string;
readonly approvalDecision?: AuditApprovalDecision;
readonly metadata?: AuditMetadata;
readonly createdAt: string;
}

export type CreateAuditEntryInput = Omit<AuditEntry, "id" | "createdAt">;
95 changes: 95 additions & 0 deletions apps/server/src/audit/audit-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";

import { createAuditLogService } from "./audit-service.js";

describe("createAuditLogService", () => {
it("records who changed what, when, through which task, agent, and runtime", async () => {
const service = createAuditLogService({
now: () => "2026-05-29T02:00:00.000Z",
nextId: () => "audit-1",
});

const entry = await service.record({
action: "task.execution",
actor: { type: "agent", id: "reviewer", displayName: "Reviewer" },
taskId: "task-1",
agentId: "reviewer",
runtimeId: "codex",
commandSummary: "git diff --cached",
diffSummary: "2 files changed",
approvalDecision: "notRequired",
metadata: { branch: "feat/example" },
});

expect(entry).toEqual({
id: "audit-1",
action: "task.execution",
actor: { type: "agent", id: "reviewer", displayName: "Reviewer" },
taskId: "task-1",
agentId: "reviewer",
runtimeId: "codex",
commandSummary: "git diff --cached",
diffSummary: "2 files changed",
approvalDecision: "notRequired",
metadata: { branch: "feat/example" },
createdAt: "2026-05-29T02:00:00.000Z",
});
expect(await service.list()).toEqual([entry]);
expect(await service.findByTask("task-1")).toEqual([entry]);
expect(await service.findByRuntime("codex")).toEqual([entry]);
expect(await service.findByActor("reviewer")).toEqual([entry]);
});

it("records patch proposal and approval events", async () => {
const service = createAuditLogService({
now: () => "2026-05-29T03:00:00.000Z",
nextId: () => "audit-approval",
});

const entry = await service.record({
action: "approval.decision",
actor: { type: "user", id: "maintainer" },
patchProposalId: "patch-1",
diffSummary: "replace /status",
approvalDecision: "approved",
});

expect(entry).toMatchObject({
id: "audit-approval",
action: "approval.decision",
patchProposalId: "patch-1",
diffSummary: "replace /status",
approvalDecision: "approved",
});
});

it("redacts secrets before persistence", async () => {
const service = createAuditLogService({ nextId: () => "audit-secret" });

const entry = await service.record({
action: "sensitive.action",
actor: { type: "user", id: "maintainer" },
commandSummary: "curl -H token=abc123 https://example.test",
diffSummary: "added api_key: sk-live-123",
approvalDecision: "approved",
metadata: {
nested: {
password: "hunter2",
safe: "kept",
},
},
});

expect(entry.commandSummary).toBe("curl -H token=[REDACTED] https://example.test");
expect(entry.diffSummary).toBe("added api_key: [REDACTED]");
expect(entry.metadata).toEqual({
nested: {
password: "[REDACTED]",
safe: "kept",
},
});
expect(JSON.stringify(await service.list())).not.toContain("abc123");
expect(JSON.stringify(await service.list())).not.toContain("hunter2");
expect(JSON.stringify(await service.list())).not.toContain("sk-live-123");
});
});
101 changes: 101 additions & 0 deletions apps/server/src/audit/audit-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type {
AuditEntry,
AuditMetadata,
AuditMetadataValue,
CreateAuditEntryInput,
} from "./audit-schema.js";

export interface AuditLogService {
readonly record: (input: CreateAuditEntryInput) => Promise<AuditEntry>;
readonly list: () => Promise<readonly AuditEntry[]>;
readonly findByTask: (taskId: string) => Promise<readonly AuditEntry[]>;
readonly findByRuntime: (runtimeId: string) => Promise<readonly AuditEntry[]>;
readonly findByActor: (actorId: string) => Promise<readonly AuditEntry[]>;
}

export interface CreateAuditLogServiceOptions {
readonly now?: () => string;
readonly nextId?: () => string;
}

const SECRET_KEY_PATTERN = /^(api[_-]?key|token|password|secret)$/i;

export function createAuditLogService(options: CreateAuditLogServiceOptions = {}): AuditLogService {
const entries: AuditEntry[] = [];
const now = options.now ?? (() => new Date().toISOString());
const nextId = options.nextId ?? (() => crypto.randomUUID());

return {
async record(input) {
const entry: AuditEntry = {
...redactAuditInput(input),
id: nextId(),
createdAt: now(),
};
entries.push(entry);
return cloneJson(entry);
},
async list() {
return cloneJson(entries);
},
async findByTask(taskId) {
return cloneJson(entries.filter((entry) => entry.taskId === taskId));
},
async findByRuntime(runtimeId) {
return cloneJson(entries.filter((entry) => entry.runtimeId === runtimeId));
},
async findByActor(actorId) {
return cloneJson(entries.filter((entry) => entry.actor.id === actorId));
},
};
}

function redactAuditInput(input: CreateAuditEntryInput): CreateAuditEntryInput {
return {
...input,
...(input.commandSummary ? { commandSummary: redactText(input.commandSummary) } : {}),
...(input.diffSummary ? { diffSummary: redactText(input.diffSummary) } : {}),
...(input.metadata ? { metadata: redactMetadata(input.metadata) } : {}),
};
}

function redactMetadata(metadata: AuditMetadata): AuditMetadata {
return redactMetadataValue(metadata) as AuditMetadata;
}

function redactMetadataValue(value: AuditMetadataValue, key = ""): AuditMetadataValue {
if (SECRET_KEY_PATTERN.test(key)) {
return "[REDACTED]";
}

if (typeof value === "string") {
return redactText(value);
}

if (Array.isArray(value)) {
return value.map((item) => redactMetadataValue(item));
}

if (typeof value === "object" && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => [
entryKey,
redactMetadataValue(entryValue, entryKey),
]),
);
}

return value;
}

function redactText(value: string): string {
return value
.replace(/\b(api[_-]?key\s*[:=]\s*)([^\s,]+)/gi, "$1[REDACTED]")
.replace(/\b(token\s*[:=]\s*)([^\s,]+)/gi, "$1[REDACTED]")
.replace(/\b(password\s*[:=]\s*)([^\s,]+)/gi, "$1[REDACTED]")
.replace(/\b(secret\s*[:=]\s*)([^\s,]+)/gi, "$1[REDACTED]");
}

function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
12 changes: 12 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,15 @@ export type {
PermissionName,
PermissionPolicy,
} from "./security/permission-policy.js";
export { createAuditLogService } from "./audit/audit-service.js";
export type { AuditLogService, CreateAuditLogServiceOptions } from "./audit/audit-service.js";
export type {
AuditAction,
AuditActor,
AuditActorType,
AuditApprovalDecision,
AuditEntry,
AuditMetadata,
AuditMetadataValue,
CreateAuditEntryInput,
} from "./audit/audit-schema.js";
8 changes: 4 additions & 4 deletions docs/development/task-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,10 +531,10 @@ Status markers:

**Tasks:**

- [ ] Record who/what initiated each action.
- [ ] Record task ID, runtime ID, command summary, diff summary, approval decision, and timestamps.
- [ ] Redact secrets before persistence.
- [ ] Commit with message `feat: add audit log service`.
- [x] Record who/what initiated each action.
- [x] Record task ID, runtime ID, command summary, diff summary, approval decision, and timestamps.
- [x] Redact secrets before persistence.
- [x] Commit with message `feat: add audit log service`.

**Acceptance Criteria:**

Expand Down