Skip to content

audit: COLLIE_AUDIT_CONTENT=none redacts by field role, fail closed - #111

Merged
AltanS merged 2 commits into
mainfrom
audit/content-redaction
Aug 17, 2026
Merged

audit: COLLIE_AUDIT_CONTENT=none redacts by field role, fail closed#111
AltanS merged 2 commits into
mainfrom
audit/content-redaction

Conversation

@AltanS

@AltanS AltanS commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Supersedes #107 — the feature and its name are @shuangwangnyc's (their commit is cherry-picked here with authorship preserved); the reshape on top changes how the redaction decides.

What changed relative to #107:

  • Field-role redaction, fail closed. none no longer redacts every string by type — a string survives only if its key is on a metadata allowlist (METADATA_KEYS in bridge/audit.ts); everything else redacts, so a detail field added later under a new name can never leak by being forgotten. Key names like keys: ["ctrl+c"] stay legible; text, promptBinding.expected, labels, cwds, and the client-declared upload filename redact.
  • Flat ⟨redacted⟩, never ⟨n chars⟩ — an exact length is itself content (a 9-character redaction next to a password prompt narrows the secret).
  • The toJSON hole is closed in both modessanitize drops function-valued properties, so a copied own toJSON can no longer re-inject unredacted content (or escape the 120-char preview cap) at stringify time.
  • AuditLog takes an options object instead of a third positional slot (the slot after append is claimed differently across branches).
  • Config JSDoc ordering, server.test.ts cfg factory, .env.example/README/ARCHITECTURE pointers.

No version bump — release deferred; the bump rides the next chore(release): commit.

Tests: 617 pass (root suite + collie-ctl lifecycle), tsc --noEmit clean.

🤖 Generated with Claude Code

Shuang Wang and others added 2 commits August 17, 2026 23:16
…ping the body

The audit trail records every write-level action, which is right — sending
keystrokes to a pane is arbitrary code execution and who-did-what-when is worth
keeping. It also records up to 120 characters of the message itself, and up to
120 characters of the prompt binding, which is a slice of whatever happened to be
on the terminal at the time.

For a personal deployment that is fine. For one where the trail is wanted but the
bodies are not, there is currently no way to have one without the other.

This adds one config value:

  COLLIE_AUDIT_CONTENT=preview   (default, byte-for-byte today's behaviour)
  COLLIE_AUDIT_CONTENT=none      every string inside `detail` becomes the length

The envelope is untouched either way: ts, action, paneId, session, device, and
every boolean and number inside detail. So submitted, textDelivered and
promptBinding.checked/passed still answer the questions the log is for.

Two small decisions, in case they are worth arguing about:

- It keeps the LENGTH rather than a shorter preview. A truncated secret is still
  a secret, and the question a reader asks of a redacted trail is usually whether
  anything was sent at all, which a count answers.
- It recurses, so nesting is covered. The prompt binding is nested and holds the
  terminal fragment; a redaction reaching only the top level would have left the
  more sensitive half in place.

sanitize and formatAuditLine take the mode as a defaulted parameter, so every
existing caller and test is unaffected.

Tested: the existing suite passes unchanged (590 tests), plus four new cases
covering the default being untouched, the envelope surviving, nothing of the
message or the terminal surviving at any depth, and the length being preserved.

Happy to adjust the naming, or drop the env var and take a constructor argument
only, if you would rather not add configuration for this.

(cherry picked from commit 497bf97)
`none` redacted every string in `detail`, which is the wrong axis: it decides
by what a value IS rather than where it CAME FROM, so it throws away the key
names and outcome flags that make the trail readable while a
content-bearing field added later under a new name is only covered by
accident of being a string.

Redaction is now by key name against a metadata allowlist in audit.ts, matched
at any depth (an array's members inherit the array's own key). A string
survives only if its key names an action parameter; everything else redacts,
so a field added later leaks nothing until someone deliberately classifies it.

Classification derived from every audit.record() call site in bridge/server.ts:

  reply             text            content   operator-typed message body
                    submit          metadata  flag
                    submitted       metadata  outcome flag
                    textDelivered   metadata  outcome flag
                    promptBinding.checked  metadata  flag
                    promptBinding.passed   metadata  flag
                    promptBinding.expected content   slice of the pane screen
                    promptBinding.reason   metadata  enum
  keys              keys            metadata  key names ("ctrl+c")
                    sent            metadata  outcome flag
                    promptBinding.* as above
  upload            filename        content   client-declared, arbitrary
                    size            metadata  number
                    saved           metadata  server-generated name
  tab.create        workspaceId     metadata  id
                    label           content   operator-typed
                    cwd             content   operator-typed path
  workspace.create  label           content   operator-typed
                    cwd             content   operator-typed path
  tab.rename        tabId           metadata  id
                    label           content   operator-typed
  tab.close         tabId           metadata  id
  pane.rename       label           content   operator-typed
  pane.close        (no detail)

`label` and `cwd` are operator-typed, so they redact — a pane label can carry
whatever the operator felt like naming a thing, and a path is a disclosure.
A reader who needs them has the ids next to them. So does an upload's
`filename`: it is whatever the phone declared, while the server-generated
`saved` keeps the entry identifiable.

Also here:

- The redaction is the constant `⟨redacted⟩`, not `⟨n chars⟩`. An exact length
  is content: an 8-character redaction next to a password prompt narrows the
  secret, and "was anything sent" is answered by the entry existing at all.
- sanitize() drops function-valued properties, in BOTH modes. It used to copy
  them through, and JSON.stringify then CALLS a copied own `toJSON` — an entry
  carrying `{ toJSON: () => secret }` re-injected its return value into the
  line, past the preview cap and past every redaction decision.
- AuditLog takes `(append, { now?, content? })` rather than three positional
  slots — the slot after `append` is claimed by different arguments on
  different branches, and a transposed argument here is a redaction quietly
  turning itself off.
- config.ts: the new field's JSDoc had landed between deviceHeader's doc and
  deviceHeader, orphaning it. Each doc sits above its own field again, and the
  field is typed as AuditContent rather than a repeated literal union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AltanS
AltanS merged commit 1e83691 into main Aug 17, 2026
1 check passed
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