@blogic-cz/agent-tools 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @blogic-cz/agent-tools
2
2
 
3
- Safe CLI wrappers for AI coding agents. 6 tools for GitHub, databases, Kubernetes, Azure DevOps, logs, and OpenCode sessions — with JSON5 config and a credential guard that blocks agents from touching secrets.
3
+ Safe CLI wrappers for AI coding agents. 7 tools for GitHub, databases, Kubernetes, Azure DevOps, logs, OpenCode sessions, and audit history — with JSON5 config and a credential guard that blocks agents from touching secrets.
4
4
 
5
5
  ## Why
6
6
 
@@ -47,7 +47,7 @@ Follow these steps to set up agent-tools in the consumer project.
47
47
 
48
48
  **Step 2 — Configure**
49
49
 
50
- Ask the user which tools they need (gh, db, k8s, az, logs, session). Create `agent-tools.json5` in the project root with **only the selected tools configured**. See [`examples/agent-tools.json5`](./examples/agent-tools.json5) for the full config reference with all options documented.
50
+ Ask the user which tools they need (gh, db, k8s, az, logs, session, audit). Create `agent-tools.json5` in the project root with **only the selected tools configured**. See [`examples/agent-tools.json5`](./examples/agent-tools.json5) for the full config reference with all options documented.
51
51
 
52
52
  Minimal starting config:
53
53
 
@@ -124,6 +124,10 @@ bun run agent-tools/example-tool/index.ts ping
124
124
  {
125
125
  $schema: "https://raw.githubusercontent.com/blogic-cz/agent-tools/main/schemas/agent-tools.schema.json",
126
126
  defaultEnvironment: "test", // optional: any string (e.g. "local", "test", "prod")
127
+ audit: {
128
+ retentionDays: 90,
129
+ dbPath: "~/.agent-tools/audit.sqlite",
130
+ },
127
131
  kubernetes: {
128
132
  default: {
129
133
  clusterId: "your-cluster-id",
@@ -145,6 +149,7 @@ bun run agent-tools/example-tool/index.ts ping
145
149
  bunx agent-tools-gh pr status
146
150
  bunx agent-tools-k8s kubectl --env test --cmd "get pods"
147
151
  bunx agent-tools-logs list --env local
152
+ bunx agent-tools-audit list --limit 20
148
153
  ```
149
154
 
150
155
  ```bash
@@ -158,6 +163,7 @@ Optionally, add script aliases to your `package.json` for shorter invocation:
158
163
  {
159
164
  "scripts": {
160
165
  "gh-tool": "agent-tools-gh",
166
+ "audit-tool": "agent-tools-audit",
161
167
  "k8s-tool": "agent-tools-k8s",
162
168
  "db-tool": "agent-tools-db",
163
169
  "logs-tool": "agent-tools-logs",
@@ -181,6 +187,7 @@ export default { handleToolExecuteBefore };
181
187
  | Binary | Description |
182
188
  | --------------------- | ---------------------------------------------------------------------------------------------------------------- |
183
189
  | `agent-tools-gh` | GitHub CLI wrapper — PR management, issues, workflows, composite commands (`review-triage`, `reply-and-resolve`) |
190
+ | `agent-tools-audit` | Audit trail browser — inspect recent tool invocations and purge old entries |
184
191
  | `agent-tools-db` | Database query tool — SQL execution, schema introspection |
185
192
  | `agent-tools-k8s` | Kubernetes tool — kubectl wrapper + structured commands (`pods`, `logs`, `describe`, `exec`, `top`) |
186
193
  | `agent-tools-az` | Azure DevOps tool — pipelines, builds, repos |
@@ -189,6 +196,65 @@ export default { handleToolExecuteBefore };
189
196
 
190
197
  All tools support `--help` for full usage documentation.
191
198
 
199
+ `agent-tools-audit` reads the same SQLite file the wrappers write to. By default that file lives at `~/.agent-tools/audit.sqlite`, and you can override both path and retention per repo with the global `audit` config section.
200
+
201
+ ## Audit Logging
202
+
203
+ Every tool invocation is automatically recorded to a local SQLite database — zero configuration required. The audit trail captures which tool ran, what arguments it received, how long it took, whether it succeeded, and which project directory it was called from.
204
+
205
+ ### How it works
206
+
207
+ Each CLI wrapper (`gh`, `k8s`, `db`, `az`, `logs`, `session`, `audit`) writes a row to `~/.agent-tools/audit.sqlite` on every execution. Logging is fire-and-forget — if the database is unavailable or write fails, the tool continues normally. Audit never blocks or slows down your workflow.
208
+
209
+ Entries older than `retentionDays` (default: 90) are automatically purged on each write.
210
+
211
+ ### Browsing the audit trail
212
+
213
+ ```bash
214
+ # Recent 20 entries (default)
215
+ bunx agent-tools-audit list
216
+
217
+ # Last 50 entries, JSON format
218
+ bunx agent-tools-audit list --limit 50 --format json
219
+
220
+ # Filter by tool
221
+ bunx agent-tools-audit list --tool gh
222
+
223
+ # Filter by project directory
224
+ bunx agent-tools-audit list --project /Users/me/my-repo
225
+
226
+ # Purge entries older than 30 days
227
+ bunx agent-tools-audit purge --days 30
228
+ ```
229
+
230
+ ### Audit Configuration
231
+
232
+ Both the database path and retention period are configurable in `agent-tools.json5`:
233
+
234
+ ```json5
235
+ {
236
+ audit: {
237
+ retentionDays: 90, // days before auto-purge (default: 90)
238
+ dbPath: "~/.agent-tools/audit.sqlite", // database file location
239
+ },
240
+ }
241
+ ```
242
+
243
+ All settings are optional — audit works out of the box with sensible defaults.
244
+
245
+ ### What gets recorded
246
+
247
+ | Column | Description |
248
+ | ----------- | ------------------------------------------------------ |
249
+ | `ts` | ISO 8601 timestamp |
250
+ | `tool` | Tool name (`gh`, `k8s`, `db`, `az`, `logs`, `session`) |
251
+ | `project` | Working directory (`process.cwd()`) |
252
+ | `args` | Command-line arguments (JSON array) |
253
+ | `duration` | Execution time in milliseconds |
254
+ | `success` | `1` (success) or `0` (failure) |
255
+ | `error` | Error message if failed, `null` otherwise |
256
+ | `exit_code` | Process exit code |
257
+
192
258
  ## Configuration
193
259
 
194
260
  Config is loaded from `agent-tools.json5` (or `agent-tools.json`) by walking up from the current working directory. Missing config = zero-config mode (works for `gh-tool`; others require config).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.4.1",
4
- "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, and sessions",
3
+ "version": "0.5.0",
4
+ "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
7
7
  "ai",
@@ -19,6 +19,7 @@
19
19
  "url": "https://github.com/blogic-cz/agent-tools.git"
20
20
  },
21
21
  "bin": {
22
+ "agent-tools-audit": "./src/audit-tool/index.ts",
22
23
  "agent-tools-az": "./src/az-tool/index.ts",
23
24
  "agent-tools-db": "./src/db-tool/index.ts",
24
25
  "agent-tools-gh": "./src/gh-tool/index.ts",
@@ -2,7 +2,7 @@
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "https://raw.githubusercontent.com/blogic-cz/agent-tools/main/schemas/agent-tools.schema.json",
4
4
  "title": "Agent Tools Configuration",
5
- "description": "Root configuration for the agent-tools package. Tool-specific sections (azure, kubernetes, database, logs) are maps of named profiles keyed by profile name. Tools choose a profile via --profile <name> (default key is 'default', single-entry maps can be auto-selected). session and credentialGuard are global sections.",
5
+ "description": "Root configuration for the agent-tools package. Tool-specific sections (azure, kubernetes, database, logs) are maps of named profiles keyed by profile name. Tools choose a profile via --profile <name> (default key is 'default', single-entry maps can be auto-selected). session, audit, and credentialGuard are global sections.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "properties": {
@@ -46,6 +46,10 @@
46
46
  "description": "Global session configuration shared across profiles.",
47
47
  "$ref": "#/definitions/SessionConfig"
48
48
  },
49
+ "audit": {
50
+ "description": "Global audit logging configuration shared across profiles.",
51
+ "$ref": "#/definitions/AuditConfig"
52
+ },
49
53
  "credentialGuard": {
50
54
  "description": "Global credential guard configuration merged with built-in defaults.",
51
55
  "$ref": "#/definitions/CredentialGuardConfig"
@@ -199,6 +203,21 @@
199
203
  },
200
204
  "required": ["storagePath"]
201
205
  },
206
+ "AuditConfig": {
207
+ "description": "Global audit configuration.",
208
+ "type": "object",
209
+ "additionalProperties": false,
210
+ "properties": {
211
+ "retentionDays": {
212
+ "description": "How many days of audit history to keep before purge on open.",
213
+ "type": "number"
214
+ },
215
+ "dbPath": {
216
+ "description": "Filesystem path for the audit SQLite database.",
217
+ "type": "string"
218
+ }
219
+ }
220
+ },
202
221
  "CliToolOverride": {
203
222
  "description": "CLI override entry that blocks a tool and recommends a safer wrapper.",
204
223
  "type": "object",
@@ -311,6 +330,10 @@
311
330
  "session": {
312
331
  "storagePath": "~/.local/share/opencode/storage"
313
332
  },
333
+ "audit": {
334
+ "retentionDays": 90,
335
+ "dbPath": "~/.agent-tools/audit.sqlite"
336
+ },
314
337
  "credentialGuard": {
315
338
  "additionalBlockedPaths": ["private/"],
316
339
  "additionalAllowedPaths": ["apps/web-app/.env.test"],
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bun
2
+ import { Command, Flag } from "effect/unstable/cli";
3
+ import { BunRuntime, BunServices } from "@effect/platform-bun";
4
+ import { Console, Effect, Layer, Option } from "effect";
5
+
6
+ import { formatOption, formatOutput, renderCauseToStderr, VERSION } from "#shared";
7
+ import { AuditService, AuditServiceLayer, withAudit } from "#shared/audit";
8
+
9
+ type AuditToolResult<T> = {
10
+ success: boolean;
11
+ executionTimeMs: number;
12
+ data?: T;
13
+ error?: string;
14
+ };
15
+
16
+ const commonFlags = {
17
+ format: formatOption,
18
+ };
19
+
20
+ const listCommand = Command.make(
21
+ "list",
22
+ {
23
+ ...commonFlags,
24
+ limit: Flag.integer("limit").pipe(
25
+ Flag.withDescription("Maximum number of recent audit entries to return"),
26
+ Flag.withDefault(20),
27
+ ),
28
+ project: Flag.optional(Flag.string("project")).pipe(
29
+ Flag.withDescription("Filter entries by exact working directory path"),
30
+ ),
31
+ tool: Flag.optional(Flag.string("tool")).pipe(
32
+ Flag.withDescription("Filter entries by tool name (gh, k8s, db, az, logs, session, audit)"),
33
+ ),
34
+ },
35
+ ({ format, limit, project, tool }) =>
36
+ Effect.gen(function* () {
37
+ const startTime = Date.now();
38
+ const audit = yield* AuditService;
39
+ const entries = yield* audit.listRecent(limit);
40
+ const toolFilteredEntries = Option.match(tool, {
41
+ onNone: () => entries,
42
+ onSome: (value) => entries.filter((entry) => entry.tool === value),
43
+ });
44
+ const filteredEntries = Option.match(project, {
45
+ onNone: () => toolFilteredEntries,
46
+ onSome: (value) => toolFilteredEntries.filter((entry) => entry.project === value),
47
+ });
48
+
49
+ const result: AuditToolResult<typeof filteredEntries> = {
50
+ success: true,
51
+ executionTimeMs: Date.now() - startTime,
52
+ data: filteredEntries,
53
+ };
54
+
55
+ yield* Console.log(formatOutput(result, format));
56
+ }),
57
+ ).pipe(Command.withDescription("List recent audit log entries"));
58
+
59
+ const purgeCommand = Command.make(
60
+ "purge",
61
+ {
62
+ ...commonFlags,
63
+ days: Flag.integer("days").pipe(
64
+ Flag.withDescription("Delete audit entries older than this many days"),
65
+ Flag.withDefault(90),
66
+ ),
67
+ },
68
+ ({ days, format }) =>
69
+ Effect.gen(function* () {
70
+ const startTime = Date.now();
71
+ const audit = yield* AuditService;
72
+ const deleted = yield* audit.purgeOlderThanDays(days);
73
+
74
+ const result: AuditToolResult<{ deleted: number; days: number }> = {
75
+ success: true,
76
+ executionTimeMs: Date.now() - startTime,
77
+ data: { deleted, days },
78
+ };
79
+
80
+ yield* Console.log(formatOutput(result, format));
81
+ }),
82
+ ).pipe(Command.withDescription("Delete old audit log entries"));
83
+
84
+ const mainCommand = Command.make("audit-tool", {}).pipe(
85
+ Command.withDescription("Audit log inspection and maintenance for agent-tools"),
86
+ Command.withSubcommands([listCommand, purgeCommand]),
87
+ );
88
+
89
+ const cli = Command.run(mainCommand, {
90
+ version: VERSION,
91
+ });
92
+
93
+ const MainLayer = AuditServiceLayer.pipe(Layer.provideMerge(BunServices.layer));
94
+
95
+ const program = withAudit("audit", cli).pipe(
96
+ Effect.provide(MainLayer),
97
+ Effect.tapCause(renderCauseToStderr),
98
+ );
99
+
100
+ BunRuntime.runMain(program, {
101
+ disableErrorReporting: true,
102
+ });
@@ -4,6 +4,7 @@ import { BunRuntime, BunServices } from "@effect/platform-bun";
4
4
  import { Console, Effect, Layer, Option } from "effect";
5
5
 
6
6
  import { formatAny, formatOption, formatOutput, renderCauseToStderr, VERSION } from "#shared";
7
+ import { AuditServiceLayer, withAudit } from "#shared/audit";
7
8
  import {
8
9
  findFailedJobs,
9
10
  getBuildJobSummary,
@@ -196,9 +197,13 @@ const cli = Command.run(mainCommand, {
196
197
  const MainLayer = AzServiceLayer.pipe(
197
198
  Layer.provideMerge(ConfigServiceLayer),
198
199
  Layer.provideMerge(BunServices.layer),
200
+ Layer.provideMerge(AuditServiceLayer),
199
201
  );
200
202
 
201
- const program = cli.pipe(Effect.provide(MainLayer), Effect.tapCause(renderCauseToStderr));
203
+ const program = withAudit("az", cli).pipe(
204
+ Effect.provide(MainLayer),
205
+ Effect.tapCause(renderCauseToStderr),
206
+ );
202
207
 
203
208
  BunRuntime.runMain(program, {
204
209
  disableErrorReporting: true,
@@ -5,6 +5,7 @@ export type {
5
5
  DbEnvConfig,
6
6
  DatabaseConfig,
7
7
  LogsConfig,
8
+ AuditConfig,
8
9
  CliToolOverride,
9
10
  CredentialGuardConfig,
10
11
  } from "./types.ts";
@@ -54,6 +54,11 @@ const LogsConfigSchema = Schema.Struct({
54
54
  remotePath: Schema.String,
55
55
  });
56
56
 
57
+ const AuditConfigSchema = Schema.Struct({
58
+ retentionDays: Schema.optionalKey(Schema.Number),
59
+ dbPath: Schema.optionalKey(Schema.String),
60
+ });
61
+
57
62
  const AgentToolsConfigSchema = Schema.Struct({
58
63
  $schema: Schema.optionalKey(Schema.String),
59
64
  azure: Schema.optionalKey(Schema.Record(Schema.String, AzureConfigSchema)),
@@ -65,6 +70,7 @@ const AgentToolsConfigSchema = Schema.Struct({
65
70
  storagePath: Schema.String,
66
71
  }),
67
72
  ),
73
+ audit: Schema.optionalKey(AuditConfigSchema),
68
74
  credentialGuard: Schema.optionalKey(CredentialGuardConfigSchema),
69
75
  defaultEnvironment: Schema.optionalKey(Schema.String),
70
76
  });
@@ -56,6 +56,11 @@ export type CredentialGuardConfig = {
56
56
  additionalDangerousBashPatterns?: string[];
57
57
  };
58
58
 
59
+ export type AuditConfig = {
60
+ retentionDays?: number;
61
+ dbPath?: string;
62
+ };
63
+
59
64
  /**
60
65
  * Root agent-tools configuration.
61
66
  *
@@ -63,7 +68,7 @@ export type CredentialGuardConfig = {
63
68
  * of named profiles. Tools select a profile via the --profile <name> flag (default = "default" key).
64
69
  * If only one profile exists, it is used automatically.
65
70
  *
66
- * session and credentialGuard are global - not per-profile.
71
+ * session, credentialGuard, and audit are global - not per-profile.
67
72
  */
68
73
  export type AgentToolsConfig = {
69
74
  $schema?: string;
@@ -79,6 +84,7 @@ export type AgentToolsConfig = {
79
84
  session?: {
80
85
  storagePath: string;
81
86
  };
87
+ audit?: AuditConfig;
82
88
  /** Global credential guard config (merged with built-in defaults, not per-profile) */
83
89
  credentialGuard?: CredentialGuardConfig;
84
90
  /** Optional default environment name (local|test|prod) used by tools when no --env flag is provided */
@@ -6,6 +6,7 @@ import { Console, Effect, Layer, Option } from "effect";
6
6
  import type { SchemaMode } from "./types";
7
7
 
8
8
  import { formatOption, formatOutput, renderCauseToStderr, VERSION } from "#shared";
9
+ import { AuditServiceLayer, withAudit } from "#shared/audit";
9
10
  import { ConfigService, ConfigServiceLayer, getDefaultEnvironment } from "#config";
10
11
  import { makeDbConfigLayer } from "./config-service";
11
12
  import { DbConnectionError } from "./errors";
@@ -121,9 +122,13 @@ const MainLayer = DbService.layer.pipe(
121
122
  Layer.provide(dbConfigLayer),
122
123
  Layer.provideMerge(ConfigServiceLayer),
123
124
  Layer.provideMerge(BunServices.layer),
125
+ Layer.provideMerge(AuditServiceLayer),
124
126
  );
125
127
 
126
- const program = cli.pipe(Effect.provide(MainLayer), Effect.tapCause(renderCauseToStderr));
128
+ const program = withAudit("db", cli).pipe(
129
+ Effect.provide(MainLayer),
130
+ Effect.tapCause(renderCauseToStderr),
131
+ );
127
132
 
128
133
  BunRuntime.runMain(program, {
129
134
  disableErrorReporting: true,
@@ -17,6 +17,21 @@ import { detectSchemaError, isValidTableName, isMutationQuery } from "./security
17
17
 
18
18
  const LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1"]);
19
19
 
20
+ export function resolveDbAccessMode(
21
+ env: string,
22
+ host: string,
23
+ hasKubectlConfig: boolean,
24
+ ): Pick<DbConfig, "allowMutations" | "host" | "needsTunnel"> {
25
+ const isLocalHost = LOCALHOST_HOSTS.has(host);
26
+ const isLocalEnvironment = env === "local";
27
+
28
+ return {
29
+ host,
30
+ needsTunnel: hasKubectlConfig && !isLocalEnvironment && isLocalHost,
31
+ allowMutations: isLocalEnvironment,
32
+ };
33
+ }
34
+
20
35
  export class DbService extends ServiceMap.Service<
21
36
  DbService,
22
37
  {
@@ -506,19 +521,21 @@ export class DbService extends ServiceMap.Service<
506
521
  throw new Error(`Unknown environment "${env}". Available: ${available}`);
507
522
  }
508
523
 
509
- const isLocal = LOCALHOST_HOSTS.has(envConfig.host);
510
- const isLocalEnvironment = env === "local";
511
- const needsTunnel = dbConfig.kubectl !== undefined && !isLocalEnvironment && isLocal;
524
+ const accessMode = resolveDbAccessMode(
525
+ env,
526
+ envConfig.host,
527
+ dbConfig.kubectl !== undefined,
528
+ );
512
529
 
513
530
  return {
514
- host: envConfig.host,
531
+ host: accessMode.host,
515
532
  user: envConfig.user,
516
533
  database: envConfig.database,
517
534
  password: envConfig.password,
518
535
  passwordEnvVar: envConfig.passwordEnvVar,
519
536
  port: envConfig.port,
520
- needsTunnel,
521
- allowMutations: isLocalEnvironment,
537
+ needsTunnel: accessMode.needsTunnel,
538
+ allowMutations: accessMode.allowMutations,
522
539
  };
523
540
  };
524
541
 
@@ -4,6 +4,7 @@ import { BunRuntime, BunServices } from "@effect/platform-bun";
4
4
  import { Effect, Layer } from "effect";
5
5
 
6
6
  import { renderCauseToStderr, VERSION } from "#shared";
7
+ import { AuditServiceLayer, withAudit } from "#shared/audit";
7
8
  import {
8
9
  issueCloseCommand,
9
10
  issueCommentCommand,
@@ -162,9 +163,15 @@ const cli = Command.run(mainCommand, {
162
163
  version: VERSION,
163
164
  });
164
165
 
165
- const MainLayer = GitHubService.layer.pipe(Layer.provideMerge(BunServices.layer));
166
+ const MainLayer = GitHubService.layer.pipe(
167
+ Layer.provideMerge(BunServices.layer),
168
+ Layer.provideMerge(AuditServiceLayer),
169
+ );
166
170
 
167
- const program = cli.pipe(Effect.provide(MainLayer), Effect.tapCause(renderCauseToStderr));
171
+ const program = withAudit("gh", cli).pipe(
172
+ Effect.provide(MainLayer),
173
+ Effect.tapCause(renderCauseToStderr),
174
+ );
168
175
 
169
176
  BunRuntime.runMain(program, {
170
177
  disableErrorReporting: true,
@@ -6,6 +6,7 @@ import { Console, Effect, Layer, Option } from "effect";
6
6
  import type { CommandResult } from "./types";
7
7
 
8
8
  import { formatOption, formatOutput, renderCauseToStderr, VERSION } from "#shared";
9
+ import { AuditServiceLayer, withAudit } from "#shared/audit";
9
10
  import { K8sService, K8sServiceLayer } from "./service";
10
11
  import { ConfigService, ConfigServiceLayer, getDefaultEnvironment, getToolConfig } from "#config";
11
12
  import type { K8sConfig } from "#config";
@@ -401,9 +402,13 @@ const cli = Command.run(mainCommand, {
401
402
  const MainLayer = K8sServiceLayer.pipe(
402
403
  Layer.provideMerge(ConfigServiceLayer),
403
404
  Layer.provideMerge(BunServices.layer),
405
+ Layer.provideMerge(AuditServiceLayer),
404
406
  );
405
407
 
406
- const program = cli.pipe(Effect.provide(MainLayer), Effect.tapCause(renderCauseToStderr));
408
+ const program = withAudit("k8s", cli).pipe(
409
+ Effect.provide(MainLayer),
410
+ Effect.tapCause(renderCauseToStderr),
411
+ );
407
412
 
408
413
  BunRuntime.runMain(program, {
409
414
  disableErrorReporting: true,
@@ -21,6 +21,7 @@ import { Console, Effect, Layer, Option, Result } from "effect";
21
21
  import type { Environment, LogResult, ReadOptions } from "./types";
22
22
 
23
23
  import { formatOption, formatOutput, renderCauseToStderr, VERSION } from "#shared";
24
+ import { AuditServiceLayer, withAudit } from "#shared/audit";
24
25
  import { ConfigService, ConfigServiceLayer, getDefaultEnvironment } from "#config";
25
26
  import { LogsConfigError, LogsNotFoundError, LogsReadError, LogsTimeoutError } from "./errors";
26
27
  import { LogsService, LogsServiceLayer } from "./service";
@@ -229,9 +230,13 @@ export const run = Command.runWith(mainCommand, {
229
230
  const MainLayer = LogsServiceLayer.pipe(
230
231
  Layer.provideMerge(ConfigServiceLayer),
231
232
  Layer.provideMerge(BunServices.layer),
233
+ Layer.provideMerge(AuditServiceLayer),
232
234
  );
233
235
 
234
- const program = cli.pipe(Effect.provide(MainLayer), Effect.tapCause(renderCauseToStderr));
236
+ const program = withAudit("logs", cli).pipe(
237
+ Effect.provide(MainLayer),
238
+ Effect.tapCause(renderCauseToStderr),
239
+ );
235
240
 
236
241
  BunRuntime.runMain(program, {
237
242
  disableErrorReporting: true,
@@ -14,6 +14,7 @@ import { Console, Effect, Layer, Result } from "effect";
14
14
  import type { MessageSummary, SessionResult } from "./types";
15
15
 
16
16
  import { formatOption, formatOutput, VERSION } from "#shared";
17
+ import { AuditServiceLayer, withAudit } from "#shared/audit";
17
18
  import { ResolvedPaths, ResolvedPathsLayer } from "./config";
18
19
  import { SessionStorageNotFoundError } from "./errors";
19
20
  import { formatDate, SessionService, SessionServiceLayer, truncate } from "./service";
@@ -261,9 +262,12 @@ export const run = Command.runWith(mainCommand, {
261
262
  version: VERSION,
262
263
  });
263
264
 
264
- const MainLayer = AppLayer.pipe(Layer.provideMerge(BunServices.layer));
265
+ const MainLayer = AppLayer.pipe(
266
+ Layer.provideMerge(BunServices.layer),
267
+ Layer.provideMerge(AuditServiceLayer),
268
+ );
265
269
 
266
- const program = cli.pipe(Effect.provide(MainLayer));
270
+ const program = withAudit("session", cli).pipe(Effect.provide(MainLayer));
267
271
 
268
272
  BunRuntime.runMain(program, {
269
273
  disableErrorReporting: true,
@@ -0,0 +1,324 @@
1
+ import { Database, constants } from "bun:sqlite";
2
+ import { mkdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
+
6
+ import { Cause, Effect, Layer, ServiceMap } from "effect";
7
+
8
+ import { loadConfig } from "#config";
9
+
10
+ const DEFAULT_AUDIT_RETENTION_DAYS = 90;
11
+ const IN_MEMORY_DB_PATH = ":memory:";
12
+
13
+ export type AuditLogEntry = {
14
+ id: number;
15
+ ts: string;
16
+ tool: string;
17
+ project: string;
18
+ args: string;
19
+ duration: number;
20
+ success: boolean;
21
+ error: string | null;
22
+ exitCode: number | null;
23
+ };
24
+
25
+ export type AuditRecord = {
26
+ tool: string;
27
+ project: string;
28
+ args: string;
29
+ duration: number;
30
+ success: boolean;
31
+ error?: string;
32
+ exitCode?: number | null;
33
+ };
34
+
35
+ type AuditServiceShape = {
36
+ readonly dbPath: string;
37
+ readonly record: (entry: AuditRecord) => Effect.Effect<void, never, never>;
38
+ readonly listRecent: (limit?: number) => Effect.Effect<readonly AuditLogEntry[], never, never>;
39
+ readonly purgeOlderThanDays: (days: number) => Effect.Effect<number, never, never>;
40
+ };
41
+
42
+ type AuditServiceOptions = {
43
+ readonly dbPath?: string;
44
+ readonly retentionDays?: number;
45
+ };
46
+
47
+ type ResolvedAuditOptions = {
48
+ dbPath: string;
49
+ retentionDays: number;
50
+ };
51
+
52
+ type AuditRow = {
53
+ id: number;
54
+ ts: string;
55
+ tool: string;
56
+ project: string;
57
+ args: string;
58
+ duration: number;
59
+ success: number;
60
+ error: string | null;
61
+ exit_code: number | null;
62
+ };
63
+
64
+ type TableInfoRow = {
65
+ name: string;
66
+ };
67
+
68
+ export class AuditService extends ServiceMap.Service<AuditService, AuditServiceShape>()(
69
+ "@agent-tools/AuditService",
70
+ ) {}
71
+
72
+ export const resolveAuditDbPath = (): string => join(homedir(), ".agent-tools", "audit.sqlite");
73
+
74
+ const expandHomePath = (path: string): string =>
75
+ path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
76
+
77
+ const isInMemoryDatabase = (dbPath: string): boolean =>
78
+ dbPath === "" || dbPath === IN_MEMORY_DB_PATH;
79
+
80
+ const toAuditEntry = (row: AuditRow): AuditLogEntry => ({
81
+ id: row.id,
82
+ ts: row.ts,
83
+ tool: row.tool,
84
+ project: row.project,
85
+ args: row.args,
86
+ duration: row.duration,
87
+ success: row.success === 1,
88
+ error: row.error,
89
+ exitCode: row.exit_code,
90
+ });
91
+
92
+ const safeToolName = (tool: string): string => tool.trim() || "unknown";
93
+
94
+ const deriveToolNameFromArgv = (): string => {
95
+ const executablePath = process.argv[1];
96
+ if (!executablePath) {
97
+ return "unknown";
98
+ }
99
+
100
+ const fileName = basename(executablePath, ".ts");
101
+ return fileName.replace(/^agent-tools-/, "").replace(/-tool$/, "");
102
+ };
103
+
104
+ const formatUnknownError = (error: unknown): string => {
105
+ if (error instanceof Error) {
106
+ return error.message;
107
+ }
108
+
109
+ if (typeof error === "object" && error !== null) {
110
+ const message = Reflect.get(error, "message");
111
+ if (typeof message === "string") {
112
+ return message;
113
+ }
114
+ }
115
+
116
+ return String(error);
117
+ };
118
+
119
+ const formatCause = (cause: Cause.Cause<unknown>): string => {
120
+ const firstFailure = cause.reasons.find(Cause.isFailReason);
121
+ if (firstFailure !== undefined) {
122
+ return formatUnknownError(firstFailure.error);
123
+ }
124
+
125
+ const firstDefect = cause.reasons.find(Cause.isDieReason);
126
+ if (firstDefect !== undefined) {
127
+ return formatUnknownError(firstDefect.defect);
128
+ }
129
+
130
+ if (Cause.hasInterruptsOnly(cause)) {
131
+ return "Interrupted";
132
+ }
133
+
134
+ return "Unknown error";
135
+ };
136
+
137
+ const extractExitCode = (cause: Cause.Cause<unknown>): number | null => {
138
+ const firstFailure = cause.reasons.find(Cause.isFailReason);
139
+ if (
140
+ firstFailure === undefined ||
141
+ typeof firstFailure.error !== "object" ||
142
+ firstFailure.error === null
143
+ ) {
144
+ return null;
145
+ }
146
+
147
+ const exitCode = Reflect.get(firstFailure.error, "exitCode");
148
+ return typeof exitCode === "number" ? exitCode : null;
149
+ };
150
+
151
+ const createTableSql = `
152
+ CREATE TABLE IF NOT EXISTS audit_log (
153
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
154
+ ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
155
+ tool TEXT NOT NULL,
156
+ project TEXT NOT NULL,
157
+ args TEXT NOT NULL,
158
+ duration INTEGER NOT NULL,
159
+ success INTEGER NOT NULL,
160
+ error TEXT,
161
+ exit_code INTEGER
162
+ ) STRICT
163
+ `;
164
+
165
+ const initializeDatabase = (db: Database, retentionDays: number): void => {
166
+ db.run("PRAGMA journal_mode=WAL");
167
+ db.run("PRAGMA synchronous=NORMAL");
168
+ db.run("PRAGMA busy_timeout=5000");
169
+ db.run(createTableSql);
170
+ const columns = db.query<TableInfoRow, []>("PRAGMA table_info(audit_log)").all();
171
+ if (!columns.some((column) => column.name === "project")) {
172
+ db.run("ALTER TABLE audit_log ADD COLUMN project TEXT");
173
+ }
174
+ db.run("UPDATE audit_log SET project = '' WHERE project IS NULL");
175
+ db.run("CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts DESC)");
176
+ db.run("CREATE INDEX IF NOT EXISTS idx_audit_log_tool_ts ON audit_log(tool, ts DESC)");
177
+ db.run("CREATE INDEX IF NOT EXISTS idx_audit_log_project_ts ON audit_log(project, ts DESC)");
178
+ db.run("DELETE FROM audit_log WHERE ts < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)", [
179
+ `-${retentionDays} days`,
180
+ ]);
181
+ };
182
+
183
+ const openDatabase = (dbPath: string, retentionDays: number): Database => {
184
+ if (!isInMemoryDatabase(dbPath)) {
185
+ mkdirSync(dirname(dbPath), { recursive: true });
186
+ }
187
+
188
+ const db = new Database(dbPath, { create: true, strict: true });
189
+ initializeDatabase(db, retentionDays);
190
+
191
+ if (!isInMemoryDatabase(dbPath)) {
192
+ try {
193
+ db.fileControl(constants.SQLITE_FCNTL_PERSIST_WAL, 0);
194
+ } catch {
195
+ return db;
196
+ }
197
+ }
198
+
199
+ return db;
200
+ };
201
+
202
+ const resolveAuditOptions = (options: AuditServiceOptions): Effect.Effect<ResolvedAuditOptions> =>
203
+ Effect.tryPromise({
204
+ try: () => loadConfig(),
205
+ catch: () => undefined,
206
+ }).pipe(
207
+ Effect.orElseSucceed(() => undefined),
208
+ Effect.map((config) => ({
209
+ dbPath: expandHomePath(options.dbPath ?? config?.audit?.dbPath ?? resolveAuditDbPath()),
210
+ retentionDays:
211
+ options.retentionDays ?? config?.audit?.retentionDays ?? DEFAULT_AUDIT_RETENTION_DAYS,
212
+ })),
213
+ );
214
+
215
+ const createAuditService = (dbPath: string, db: Database | null): AuditServiceShape => ({
216
+ dbPath,
217
+ record: (entry) =>
218
+ db === null
219
+ ? Effect.void
220
+ : Effect.sync(() => {
221
+ db.run(
222
+ "INSERT INTO audit_log (tool, project, args, duration, success, error, exit_code) VALUES (?, ?, ?, ?, ?, ?, ?)",
223
+ [
224
+ safeToolName(entry.tool),
225
+ entry.project,
226
+ entry.args,
227
+ entry.duration,
228
+ entry.success ? 1 : 0,
229
+ entry.error ?? null,
230
+ entry.exitCode ?? null,
231
+ ],
232
+ );
233
+ }),
234
+ listRecent: (limit = 20) =>
235
+ db === null
236
+ ? Effect.succeed([])
237
+ : Effect.sync(() => {
238
+ const rows = db
239
+ .query<AuditRow, [number]>(
240
+ "SELECT id, ts, tool, project, args, duration, success, error, exit_code FROM audit_log ORDER BY id DESC LIMIT ?",
241
+ )
242
+ .all(limit);
243
+ return rows.map(toAuditEntry);
244
+ }),
245
+ purgeOlderThanDays: (days) =>
246
+ db === null
247
+ ? Effect.succeed(0)
248
+ : Effect.sync(() => {
249
+ const result = db.run(
250
+ "DELETE FROM audit_log WHERE ts < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)",
251
+ [`-${days} days`],
252
+ );
253
+ return result.changes;
254
+ }),
255
+ });
256
+
257
+ export const makeAuditServiceLayer = (options: AuditServiceOptions = {}) => {
258
+ return Layer.effect(
259
+ AuditService,
260
+ Effect.flatMap(resolveAuditOptions(options), ({ dbPath, retentionDays }) =>
261
+ Effect.acquireRelease(
262
+ Effect.sync(() => {
263
+ try {
264
+ return openDatabase(dbPath, retentionDays);
265
+ } catch {
266
+ return null;
267
+ }
268
+ }),
269
+ (db) =>
270
+ db === null
271
+ ? Effect.void
272
+ : Effect.sync(() => {
273
+ db.close(false);
274
+ }).pipe(Effect.ignore),
275
+ ).pipe(Effect.map((db) => createAuditService(dbPath, db))),
276
+ ),
277
+ );
278
+ };
279
+
280
+ export const AuditServiceLayer = makeAuditServiceLayer();
281
+
282
+ const safelyRecord = (entry: AuditRecord) =>
283
+ Effect.gen(function* () {
284
+ const audit = yield* AuditService;
285
+ yield* audit.record(entry);
286
+ }).pipe(Effect.ignore);
287
+
288
+ export const withAudit = <A, E, R>(
289
+ toolName: string,
290
+ program: Effect.Effect<A, E, R>,
291
+ ): Effect.Effect<A, E, R | AuditService> =>
292
+ Effect.suspend(() => {
293
+ const startedAt = Date.now();
294
+ const project = process.cwd();
295
+ const args = process.argv.slice(2).join(" ");
296
+ const tool = safeToolName(toolName || deriveToolNameFromArgv());
297
+
298
+ return Effect.matchCauseEffect(program, {
299
+ onFailure: (cause) =>
300
+ Effect.flatMap(
301
+ safelyRecord({
302
+ tool,
303
+ project,
304
+ args,
305
+ duration: Date.now() - startedAt,
306
+ success: false,
307
+ error: formatCause(cause),
308
+ exitCode: extractExitCode(cause),
309
+ }),
310
+ () => Effect.failCause(cause),
311
+ ),
312
+ onSuccess: (value) =>
313
+ Effect.flatMap(
314
+ safelyRecord({
315
+ tool,
316
+ project,
317
+ args,
318
+ duration: Date.now() - startedAt,
319
+ success: true,
320
+ }),
321
+ () => Effect.succeed(value),
322
+ ),
323
+ });
324
+ });