feat: /add-audit skill — opt-in local audit log for the ncl surface#2987
Closed
moshe-nanoco wants to merge 1 commit into
Closed
feat: /add-audit skill — opt-in local audit log for the ncl surface#2987moshe-nanoco wants to merge 1 commit into
moshe-nanoco wants to merge 1 commit into
Conversation
af0d7c6 to
a9e5231
Compare
22c597c to
8bf9684
Compare
8bf9684 to
2c5dcaf
Compare
9cb2dae to
a9e5231
Compare
2c5dcaf to
8bf9684
Compare
8bf9684 to
b56f9c5
Compare
This was referenced Jul 12, 2026
b56f9c5 to
84dd32b
Compare
Ships the local audit log as an install skill, scoped to the ncl command surface: one withAudit(dispatch) composition at the definition site covers both transports and the grant-carrying approved replay; holds are recorded as pending events correlated by approval id (recovered from the hold row), so --correlation returns the whole gated chain. src/audit/ installs as a domain-free leaf — schema v1, NDJSON day-files under data/audit/ (bucketed by event time), fail-open + loud emit that brackets the dispatcher so a thrown command still leaves a record, post-write exporter hooks, boot writability refusal, self-contained retention prune. `ncl audit list` is host + global-scope only (off the group-scope allowlist, fails closed), validates its filter flags (a typo is rejected, not silently ignored), reads --since/--until as UTC, and streams the full window on --format ndjson (no silent 100-row cap). Core reach-ins: the dispatch composition and the resource-barrel import, both guarded by a shipped AST/behavior wiring test that also pins guard conformance clean. Value-free recording model (hardened per the max-effort review): the event stores WHO did WHICH action to WHAT target and the outcome — never raw argument values. `details` carry the flag names that were passed plus a small allowlist of governance-relevant enum values (role, mode, session_mode, cli_scope, access, engage_mode, sender_scope, provider, model) echoed verbatim; every other value is name-only, and a failure keeps the error CODE, never the free-text message. This replaces the key-pattern redactor outright — a secret in an error message, a malformed-JSON arg, or an innocent key can no longer land, because no free-form value is ever written. Target ids surface structurally in resources. Other review fixes: emit-on-throw; an approved replay records as `approved` (rejected dropped until the approval-lifecycle increment); --help probes record under a neutral cli.help action instead of masquerading as the real verb succeeding; --limit 0 is honored as zero rows; and an exporter hook whose module loads after boot still runs its one-time init() — the registration seam is import-order-insensitive (onEvent/maintain/shutdown already read the live list; init() now self-heals on late registration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
84dd32b to
0aca34d
Compare
This was referenced Jul 13, 2026
Contributor
Author
|
Obsolete — the base branch feat/guard-seam is merged to main, and the audit skill now ships in the enterprise repo (nanocoai/nanoclaw-ent#12). Closing. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The local audit log as an install skill (
/add-audit), scoped to the ncl command surface: every dispatch outcome on both transports — host socket and container — including scope denials, approval holds, and approved replays, recorded as SIEM-shaped, append-only NDJSON day-files underdata/audit/<UTC-day>.ndjson. Read back withncl audit list(filters +--format ndjson); in-process exporters plug in viaregisterAuditHook. Off by default —AUDIT_ENABLED=trueis the master switch; nothing is persisted (anddata/audit/is never created) until an operator opts in.Stacked on
feat/guard-seam— the diff is one commit, 19 files, all additive: 18 under.claude/skills/add-audit/plus one CHANGELOG bullet. Trunk code is untouched — audit is not core NanoClaw; it ships as a skill (per the audit-implementations review verdict, hub:engineering/reviews/audit-log-implementations).Why
"Who changed this group's config, when, and was it approved?" has no durable answer today:
logs/nanoclaw.logis free text interleaved with routing noise, andpending_approvalsrows are deleted once decided — working state, not history. Design refs on the hub:engineering/requirements/audit-logandengineering/discovery/audit-log-decisions(authoritative for every design choice here).How it works
src/audit/— a domain-free leaf: event schema (schema_version: 1, projects onto OCSF/ECS), env config, day-file store (appendFileSync, host single-writer, bucketed by event day), fail-open + loud emit, post-write hooks, newest-first reader, boot/maintenance wiring — plus one CLI adapter (src/cli/dispatch.audit.ts) and the read-onlyncl auditresource.actor(host:<os-user>or the agent group),origin(transport, session, channel), the dottedactionfrom the guard catalog (e.g.groups.config.add-mcp-server), touched/attemptedresources,outcome(success · failure · denied · pending · approved),correlation_id, anddetails.detailsnever holds raw argument values — it carries the sorted list of flag names that were passed, plus a small allowlist of governance-relevant enum values (role,mode,session_mode,cli_scope,access,engage_mode,sender_scope,provider,model) echoed verbatim when present. A failure keeps the error code, never the free-text message. Target identifiers (ids, users, groups) surface structurally inresources, not indetails. There is no value redactor: a secret in an error string, a malformed-JSON arg, or an unanticipated key can't land, because no free-form value is ever written.src/cli/dispatch.ts:export const dispatch = withAudit(dispatchInner)at the definition site — one composition covers the socket server, the containercli_requestpath, and the in-module approved-replay call.src/cli/resources/index.ts:import './audit.js';opts.grant.approval_id), and a fresh hold is correlated by recovering the row it just created (payloadframe.idmatch) — so--correlation <approval-id>returns the whole gated chain (pending → terminal).auditis deliberately not inGROUP_SCOPE_RESOURCES: group-scoped agents fail closed; host +cli_scope: globalcallers only. A disabled box answersncl audit listwithaudit log is disabled — set AUDIT_ENABLED=trueinstead of an empty list, and an enabled box refuses to boot ifdata/audit/isn't writable.registerResourceauto-derives theaudit.listcatalog entry (access: 'open'— it never holds, so it cannot introduce an approve-continuation gap), verified by the shipped wiring test that mirrors guard's CI conformance check.Deviations from the decisions doc (ncl-only v1 scope)
approval-pendingresponse is this increment's record of a hold. The decisions doc assigns pending events to arequestApprovaldecorator; that machinery belongs to the approval-lifecycle increment (depends on the approval-contract branch) and attaches later as pure adds (new*.audit.tsadapters + observer subscriptions —src/audit/needs zero rework). Consequence: the pending event does not name the picked approver yet, and terminalapproved/rejecteddecision events are deferred to that increment (this PR records the approved replay asapproved).src/audit/config.ts(still read viareadEnvFile), not coreconfig.ts— one fewer core edit for the skill to apply.index.tsedit.commandGuardAction, fallbackcli.<name>), not the doc's older spellings; a--helpprobe records under the neutralcli.helpaction rather than the real verb.action, the attempted target as aresource, anddenied— the foreign id the caller aimed at is still visible, structurally, without echoing any raw arg value.How tested
pnpm run buildgreen in both states; zero changes undercontainer/.src/cli/dispatch.audit.test.tsdrives the real wrappeddispatchthrough the real registry and real guard (audit force-enabled, the store's append captured) — asserting the recorded event for every outcome: host success (socket origin), agent success after group auto-fill, scope denial (attempted-resource type recorded, value-freedetails), handler failure (error code only), a hold recorded aspendingcorrelated to the approval row it created, an uncorrelated hold (no approver), a full approved replay recorded asapprovedsharing the grant's approval id, a--helpprobe recorded ascli.help, and an emit-on-throw case (a thrown handler still leaves a failure record). The value-free guarantee is asserted in the stored bytes:details.argsholds only flag names; the allowlist echoes an enum value (role: 'admin') while the accompanying--uservalue is absent fromdetailsand surfaces only as aresource; and a secret passed as an arg value never appears in the written line.src/audit-wiring.test.tsAST check pins the reach-in shape (export const dispatch = withAudit(dispatchInner), inner kept unexported), the resource's absence from the group-scope allowlist, guard conformance staying clean with the resource registered, and the emit-invariant sweep (emitAuditEventonly insrc/audit/and*.audit.ts).ncl audit listfilter, disabled-box error, group-scoped refusal). Those runtime plumbing paths — transports, correlation, filters, boot/disabled behavior — are unchanged by the rebase and the value-free rework; the recording-model change itself is covered by the shipped unit suite above (which asserts the stored bytes are value-free) rather than re-derived from the older smoke.Usage
Run
/add-audit(copies files, applies the two edits, enablesAUDIT_ENABLED=true+AUDIT_RETENTION_DAYS=90, builds, restarts). Then:Removal:
.claude/skills/add-audit/REMOVE.mdreverses every change (day-files are left in place — they're the operator's records).🤖 Generated with Claude Code