REQ-O-030: Opt-In Audit Log and audit-log Command
Tier: Opt-In | Priority: P2
Source: §33 Observability & Audit Trail
Addresses: Severity: Medium / Token Spend: Medium / Time: High / Context: Medium
Description
The framework MUST provide a persistent audit log that is off by default.
Enabling. The application enables it with app.enable_audit_log(), optionally passing a path. The operator controls it through the prefixed environment variable <PREFIX>AUDIT_LOG (REQ-F-073), which accepts exactly three kinds of value:
| Value | Effect |
|---|---|
1 |
On, at the application's configured path, or at the default path when the application set none |
0 |
Off, even when the application enabled it |
| An absolute path | On, at that path; overrides both the application's path and the default |
Any other value (true, off, an empty string, a relative path) fails every invocation except --help and --version with exit 2 (ARG_ERROR) and error code INVALID_AUDIT_LOG_SETTING, naming the variable and the accepted values. The operator setting takes precedence over the application, so an agent runtime can turn the log on for a CLI whose author never did, and off for one that did. When the log is disabled, the framework MUST NOT create any file or directory for it. Whether or not the log is enabled, the manifest MUST declare <PREFIX>AUDIT_LOG and the session variable, each in its one home under REQ-F-073: root env_vars with a description (ManifestResponse 3.5), or the env_vars of the flag it already backs, as when the framework reuses a variable behind a flag as the session variable.
What is logged. When enabled, the framework MUST append one entry per invocation that resolves to a command, whatever its outcome. This includes argument errors raised after the command resolves, --validate-only (REQ-O-009), --dry-run (REQ-C-004, REQ-O-048), and a destructive command refused for lacking --confirm-destructive (REQ-O-021): an incident review needs exactly these. An invocation that never resolves to a command (an unknown command or group) is not logged, because its arguments cannot be redacted without a declared schema and raw argv is never written. A streaming command (REQ-O-004) is one invocation and gets one entry, written once its terminal line fixes the exit code, never one entry per event. Invocations that do no work are not logged: --help, --version, shell completion, schema or manifest introspection, and audit-log itself.
Entry. Each entry is one line of JSON matching audit-log-entry.json:
timestamp,command,exit_code,duration_ms, andrequest_idare always present.commandMUST equal the invocation'smeta.commandexactly, in whichever spelling the framework uses consistently for it: space-separated (config set) or dot-separated (config.set)argsis the parsed argument map after REQ-F-034 redaction, never the raw argv. Framework flags that change what the invocation did (--dry-run,--validate-only,--confirm-destructive,--no-injection-protection) appear in it under their flag names. A passthrough command (REQ-C-031) has no parsed map for the arguments it forwards, soargsrecords them as"argv": "[OMITTED]"warningslists thecodeof every entry in the response'swarnings[], so an over-privileged credential (CREDENTIAL_OVER_PRIVILEGED, REQ-O-047) or disabled injection protection (INJECTION_PROTECTION_DISABLED, REQ-O-023) is recorded as a queryable codeeffectsis present on the entry for a mutating stream (REQ-O-004): the per-effect counts of its summary line, or, when the stream failed, of the item lines it emitted before the error envelope. A dry-run stream's counts are itswould_*valuestrace_idis present whenTOOL_TRACE_IDis set (REQ-F-025)session_idis present when the agent runtime sets the framework's session variable. A framework that already reads an agent session id from a prefixed environment variable (for example to scope REQ-C-007 idempotency keys) uses that variable; otherwise the session variable is<PREFIX>SESSION_ID. The framework reads exactly one variable, records its value verbatim, derivessession_idfrom nothing else, and documents the variable's name
Each entry MUST NOT exceed 16 KiB. When a serialized entry would, the framework replaces values in args, largest first, with the string [TRUNCATED] until it fits, and sets truncated: true on the entry. A command taking a large payload therefore still produces one bounded entry that fits a single write.
Bounds. The log MUST be bounded by the framework, not by the operator. It rotates when the active file exceeds a maximum size, keeps a maximum number of rotated files, and removes data older than a maximum age; the application MUST be able to configure all three. The defaults SHOULD be 10 MB, 5 rotated files, and 30 days. A framework MAY ship smaller defaults, but its default size bound, max_size × (max_rotated_files + 1), SHOULD NOT exceed 60 MB, so a log the operator turns on for a CLI whose author configured nothing has a predictable footprint. The defaults of REQ-F-042 do not apply to the audit log. The maximum age applies to entries, not only to rotated files: before appending, the framework rotates the active file when its first entry is older than the maximum age, then deletes rotated files whose last modification is older than the maximum age. Total disk usage therefore never exceeds max_size × (max_rotated_files + 1) plus one entry. Entries are append-only within a file; rotation and pruning are the only operations that remove data. Each entry MUST be written as a single O_APPEND write of one complete line so that concurrent invocations never interleave, and rotation MUST be safe under concurrent invocations. Rotation and pruning run only while the log is enabled; tool cleanup (REQ-O-027) never removes the audit log, so the files of a log that is later disabled remain until the operator deletes them.
Permissions. The log holds every command's arguments, and redaction covers declared secrets only: hostnames, account ids, paths, and query text stay in clear. The framework MUST create the log file with mode 0600 and any directory it creates for it with mode 0700 (an owner-only ACL on Windows), regardless of the process umask. It MUST NOT change the mode of an existing file or directory.
Location. The default path is $XDG_STATE_HOME/<toolname>/audit.jsonl (falling back to ~/.local/state/<toolname>/audit.jsonl), or the platform's per-user log directory outside XDG systems. While the log is enabled, the framework MUST list its path in the manifest's filesystem side effects as type log (REQ-C-011), without clearable_with, and MUST set meta.audit_log_path on every response; while it is disabled, neither appears.
Write failures. The framework writes the entry after the command's exit code is known and before it emits the response. A failed audit write (read-only filesystem, full disk, permission error) MUST NOT change the command's exit code or output data; the framework adds an AUDIT_LOG_UNAVAILABLE warning to warnings[] instead. When the output carries no envelope (--format plain, tsv, or a jsonl stream), the framework writes that warning to stderr as one JSON WarningDetail line.
Querying. The framework MUST register a built-in tool audit-log command on every CLI, since the operator can enable the log for any of them. It reads across the active and rotated files and accepts:
--since <duration or ISO datetime>: a duration is a positive integer followed bys,m,h, ord(30s,15m,1h,7d); a datetime is ISO 8601 with a UTC offset. Any other value exits2--command <path>: matches a command path exactly or as a whole-word prefix, and MUST accept both spellings, space-separated (config set) and dot-separated (config.set), whichever one the entries use. The prefix rule holds in each spelling:configmatchesconfig setandconfig.setbut neverconfigure--trace-id <id>: matchestrace_idexactly--limit <n>: keeps the newestnmatching entries--cursor <token>: continues from themeta.pagination.next_cursorof a previousaudit-loganswer made with the same filters, returning the next-older batch of up tonmatching entries. The token is opaque and stateless (REQ-O-003); a malformed or expired token, or one reused with different filters, fails asINVALID_CURSOR(REQ-O-003)--format jsonl: one entry per line, followed by the pagination summary line
Filters combine with AND. Entries are always returned oldest first.
audit-log is a list command and MUST accept --cursor as well as --limit, so it satisfies REQ-F-018 in full. Its default answer is one buffered envelope with the entries in data.entries and meta.pagination:
returnedis the number of entries in the response, andtotalis the number of entries that matched the filters, ornullwhen the framework does not count them- When
--limitleft out older matching entries,truncatedandhas_morearetrueandnext_cursoris a non-null opaque token. Passing it as--cursorwith the same filters returns the next-older batch of up tonmatching entries, still oldest first within the page - When no older entry matches,
truncatedandhas_morearefalseandnext_cursorisnull
Cursor anchor. The audit-log cursor anchors on the position of the last entry the page returned, its timestamp plus request_id, never on a file offset or a rotated-file index, so rotation between pages does not move it. The next page returns the matching entries still older than the anchor, without an error or a warning, even when entries were removed in between: pruning removes the oldest entries, so the walk ends sooner, total may shrink from one page to the next, and has_more and next_cursor stay accurate for what remains. Entries appended after the first page are newer than the anchor and never appear on a later page; a caller that wants them starts a new query without --cursor.
A framework MAY make audit-log streaming-default under REQ-O-004, declaring streaming_default: true in the manifest; --no-stream then MUST return the buffered data.entries envelope. In streaming mode (--format jsonl or the streaming default) the same pagination fields go on the final summary line, as REQ-O-004 requires.
With the log disabled, audit-log exits 4 (PRECONDITION) with error code AUDIT_LOG_DISABLED and a fix_required naming <PREFIX>AUDIT_LOG, so an agent can tell "nothing was recorded" from "nothing happened".
Acceptance Criteria
- With the log not enabled by the application or the operator, running any command creates no audit file or directory,
meta.audit_log_pathis absent, and the manifest lists nologside effect for it <PREFIX>AUDIT_LOG=1 tool deployappends one entry to the default path;<PREFIX>AUDIT_LOG=0suppresses the log for an application that enabled it- With the application's path set,
<PREFIX>AUDIT_LOG=1writes to the application's path and<PREFIX>AUDIT_LOG=/tmp/a/audit.jsonlwrites to/tmp/a/audit.jsonl <PREFIX>AUDIT_LOG=true tool deployand<PREFIX>AUDIT_LOG=logs/audit.jsonl tool deployexit2with error codeINVALID_AUDIT_LOG_SETTINGand write nothing- With the log enabled, every response carries
meta.audit_log_pathand the manifest lists that path as alogfilesystem side effect - An entry is appended for a command that exits non-zero, for
--validate-only, for--dry-run, and for a destructive command refused without--confirm-destructive - An unknown command appends no entry
- A mutating stream that emits three events appends one entry whose
effectsequals its summary line'seffects; when it fails after two events, the entry's counts cover those two tool --help,tool --version, shell completion,tool manifest,--schema, andtool audit-logappend no entry- The entry for a command invoked with a secret argument does not contain the secret value
- An invocation that emits
CREDENTIAL_OVER_PRIVILEGEDhas that code in the entry'swarnings - With the framework's session variable set to
s-1, the entry hassession_id: "s-1"; without it, the entry has nosession_id - A framework that has no other session variable uses
<PREFIX>SESSION_ID, and its documentation names the session variable it reads tool manifestlists<PREFIX>AUDIT_LOGin rootenv_varswith adescription, and the session variable there too unless it backs a flag, whether or not the log is enabled- Every entry validates against
audit-log-entry.json - An invocation with a 50 MB argument appends one entry of at most 16 KiB with
truncated: true - With a umask of
0022, a freshly created audit log file has mode0600and a directory created for it has mode0700 - The audit log is valid JSONL after concurrent invocations from parallel sessions
- The application can set the maximum size, rotated file count, and maximum age, and the log honors each value
- Total audit log disk usage stays bounded by the configured size and retention across unlimited invocations
- An active file whose first entry is older than the maximum age is rotated on the next write, and rotated files older than the maximum age are deleted
- With the log path unwritable, the command exits with its normal exit code and
warnings[]containsAUDIT_LOG_UNAVAILABLE; with--format plain, the warning appears on stderr tool audit-log --since 1h --format jsonlreturns all invocations from the past hour, one per line, oldest firsttool audit-log --since 2026-03-17T14:00:00Zreturns only entries at or after that time;--since 1wexits2- Every entry's
commandequals themeta.commandof the invocation it records tool audit-log --command configreturns entries forconfig setandconfig getbut notconfigure- For a framework whose
meta.commandis dot-separated, entries recordconfig.set;tool audit-log --command configand--command "config set"both return it, and neither returnsconfigure tool audit-log --trace-id abc123returns only entries with that trace ID- With more than 100 matching entries,
--limit 100returns the newest 100, oldest first tool audit-logwithout--format jsonlreturns one envelope with the entries indata.entriesandmeta.pagination- With 250 matching entries,
--limit 100returns the newest 100 withreturned: 100,truncated: true,has_more: true, and a non-nullnext_cursor;totalis250ornull - Passing that
next_cursoras--cursorwith the same filters and--limit 100returns the 100 entries before those, oldest first; passing the second page'snext_cursorreturns the oldest 50 withreturned: 50,truncated: false,has_more: false, andnext_cursor: null - A malformed
--cursorvalue exits2(ARG_ERROR) with error codeINVALID_CURSOR(REQ-O-003) - A
next_cursorfromtool audit-log --command config --limit 100passed totool audit-log --command deploy --cursor <token>exits2with error codeINVALID_CURSOR - With 250 matching entries, when pruning removes the oldest 120 between the first and second page, passing the first page's
next_cursorreturns the remaining 30 older matches withreturned: 30,has_more: false, andnext_cursor: null, and no error or warning - An entry appended between the first and second page does not appear on the second page
- With
--format jsonl, the final line is a summary line carrying the same pagination fields - When
audit-logdeclaresstreaming_default: truein the manifest,tool audit-log --no-streamreturns the buffereddata.entriesenvelope - With the log disabled,
tool audit-logexits4with error codeAUDIT_LOG_DISABLED
Schema
Types: audit-log-entry.json · response-envelope.json · manifest-response.json
Each log line and each item of the audit-log command's data.entries is an AuditLogEntry. meta.audit_log_path is present on every response while the log is enabled. The enabled log appears in the manifest as a FilesystemSideEffect of type log. <PREFIX>AUDIT_LOG and the session variable are EnvVarEntry items in the manifest's root env_vars, unless the session variable already backs a flag.
Wire Format
meta.audit_log_path in the response envelope when the log is enabled:
{
"ok": true,
"data": { "deployed": true },
"error": null,
"warnings": [],
"meta": {
"exit_code": 0,
"duration_ms": 412,
"request_id": "req_01HZ",
"audit_log_path": "/home/user/.local/state/mytool/audit.jsonl"
}
}
Querying the log:
$ tool audit-log --since 1h --format jsonl
{"timestamp":"2026-03-17T14:00:01Z","command":"deploy","args":{"env":"prod","token":"[REDACTED]"},"exit_code":0,"duration_ms":1247,"request_id":"req-001","trace_id":"abc123","warnings":[]}
{"timestamp":"2026-03-17T14:05:22Z","command":"delete","args":{"resource_id":"r-42","dry_run":true},"exit_code":0,"duration_ms":8,"request_id":"req-002","trace_id":"def456","session_id":"s-1","warnings":["CREDENTIAL_OVER_PRIVILEGED"]}
{"_summary":true,"total":2,"returned":2,"truncated":false,"has_more":false,"next_cursor":null,"duration_ms":14}
Querying a disabled log:
{
"ok": false,
"data": null,
"error": {
"code": "AUDIT_LOG_DISABLED",
"message": "The audit log is not enabled for this tool",
"retryable": false,
"fix_required": "Set TOOL_AUDIT_LOG=1 for future invocations; invocations made while the log was disabled were not recorded"
},
"warnings": [],
"meta": { "exit_code": 4, "duration_ms": 3 }
}
Example
Enabled by the application, with the default bounds made explicit:
app = Framework("tool")
app.enable_audit_log(max_size_mb=10, max_rotated_files=5, max_age_days=30)
Enabled by the operator for a CLI that never opted in:
$ TOOL_AUDIT_LOG=1 tool deploy --env prod --token abc123
→ ~/.local/state/tool/audit.jsonl (mode 0600) appended:
{"timestamp":"2026-03-17T14:00:01Z","command":"deploy","args":{"env":"prod","token":"[REDACTED]"},"exit_code":0,...}
$ tool deploy --env prod
→ no audit file written (not enabled)
$ TOOL_AUDIT_LOG=yes tool deploy --env prod
→ exit 2, INVALID_AUDIT_LOG_SETTING: accepted values are 1, 0, or an absolute path
Related
| Requirement | Tier | Relationship |
|---|---|---|
| REQ-F-024 | F | Provides: request_id and command values written to each audit log entry |
| REQ-F-025 | F | Provides: the caller-supplied trace ID recorded in each entry |
| REQ-F-039 | F | Provides: duration_ms value written to each audit log entry |
| REQ-F-034 | F | Enforces: secret fields are redacted in every audit log entry and query result |
| REQ-F-042 | F | Composes: the audit log uses the same rotation mechanism with its own bounds |
| REQ-F-073 | F | Consumes: <PREFIX>AUDIT_LOG and the session variable (<PREFIX>SESSION_ID unless the framework already reads a prefixed one) follow the tool env var prefix and are declared in root env_vars, or on the flag the session variable already backs |
| REQ-F-018 | F | Provides: meta.pagination on every audit-log answer |
| REQ-O-003 | O | Consumes: --limit and the stateless --cursor token that pages through audit-log |
| REQ-O-004 | O | Extends: audit-log MAY be streaming-default, with --no-stream returning the buffered envelope |
| REQ-C-011 | C | Extends: the enabled audit log appears in the declared filesystem side effects |
| REQ-F-004 | F | Extends: meta.audit_log_path is added to the standard response meta |
| REQ-O-009 | O | Consumes: --validate-only invocations are logged |
| REQ-O-021 | O | Consumes: destructive confirmation and refusal are recorded through args |
| REQ-O-023 | O | Consumes: INJECTION_PROTECTION_DISABLED is recorded in the entry's warnings |
| REQ-O-027 | O | Composes: tool cleanup leaves the audit log in place |
| REQ-O-047 | O | Consumes: CREDENTIAL_OVER_PRIVILEGED is recorded in the entry's warnings |
| REQ-C-031 | C | Consumes: a passthrough command's forwarded argv is recorded as [OMITTED] |