Skip to content

feat(proxy): redact a finding instead of refusing the request - #104

Merged
CharmingGroot merged 1 commit into
mainfrom
feat/redaction
Sep 1, 2026
Merged

CharmingGroot merged 1 commit into
mainfrom
feat/redaction

Conversation

@CharmingGroot

Copy link
Copy Markdown
Owner

Closes #102.

A refusal stops the leak and the work. An engineer whose agent is summarising a support ticket does not want the ticket blocked — they want the ticket without the customer's number in it. Every comparable tool ships redaction for that reason; pasu could only say no.

Blocking stays the default

Stopping is the safe answer for a rule nobody has decided about. Three precedence rules, all in the same direction:

  • an explicit --block-rule beats an explicit --redact-rule — where an operator has said both about one rule they have contradicted themselves, and the safe reading of a contradiction in a security policy is the stricter one
  • a blocking finding beats a redactable one in the same request: sending a body with one rule removed while another said stop answers the wrong question
  • a finding that cannot be replaced safely (a span off a character boundary, a body that will not rebuild) refuses rather than forwards — failing open there would send the thing that was found

One-way, deliberately

No original is kept. Nothing can restore it, and this proxy never becomes a store of exactly the data it exists to stop from moving, reachable over the network. Reversible substitution is a genuinely different security posture and deserves its own decision rather than being inherited from whichever was easier to build.

The placeholder preserves neither length nor shape. A mask that kept those leaks the value it hid, and a digit-shaped mask over a national ID leaks most of it. Overlapping findings merge and name every contributing rule, sorted, so the body does not depend on which inspector ran first.

Extraction and rewriting share one traversal

Two functions deciding separately which fields hold prose would drift, and the day they did, a scanner would be reading a field the redactor no longer edits. The body is rebuilt from the parsed value rather than string-replaced, so what leaves is still valid JSON in the provider's shape.

Tested against what the provider received

Not against what the proxy returned — that would prove only that it did not refuse. A recording mock upstream asserts the value is absent, the placeholder is present, the rest of the prompt survived (otherwise this is just a block), and the body still parses as JSON. A blocked rule never reaches the mock at all. Every occurrence is replaced, not the first.

Mutation-checked: discarding the rewritten body fails two of them.

Also

The startup lines claimed a match was refused while redaction was on. They now say what is actually configured:

pasu-proxy: requests are inspected by pii-kr (rule ids are logged, never the matched text)
pasu-proxy: a match rewrites the prompt rather than refusing it. The replacement is one-way — no original is kept, so nothing can restore it
pasu-proxy: except ko-rrn, which still refuse the request

🤖 Generated with Claude Code

A refusal stops the leak and the work with it. An engineer whose agent is
summarising a support ticket does not want the ticket blocked — they want the
ticket without the customer number in it. Every comparable tool ships redaction
for that reason; pasu could only say no.

Blocking stays the default, and an explicit block beats an explicit redact.
Stopping is the safe answer for a rule nobody has decided about, and where an
operator has said both things about one rule they have contradicted themselves —
the safe reading of a contradiction in a security policy is the stricter one.
A blocking finding also wins over a redactable one in the same request: sending
a body with one rule removed while another said stop answers the wrong question.

One-way, deliberately. No original is kept, so nothing can restore it and this
proxy never becomes a store of exactly the data it exists to stop from moving,
reachable over the network. Reversible substitution is a different security
posture and belongs to its own decision rather than being inherited from
whichever was easier to build.

The placeholder tells nothing. It preserves neither length nor shape; a mask
that kept those leaks the value it hid, and a digit-shaped mask over a national
ID leaks most of it. Overlapping findings merge and name every rule that
contributed, sorted, so the body does not depend on which inspector ran first.

Extraction and rewriting now share one traversal. Two functions deciding
separately which fields hold prose would drift, and the day they did, a scanner
would be reading a field the redactor no longer edits. The body is rebuilt from
the parsed value rather than string-replaced, so what leaves is still valid JSON
in the provider shape.

Findings that cannot be replaced safely — a span off a character boundary, a
body that will not rebuild — refuse rather than forward. Failing open there
would send the thing that was found.

Tested against what the provider actually received, not against what the proxy
returned: a recording mock upstream asserts the value is absent, the placeholder
is present, the rest of the prompt survived, and the body still parses. A
blocked rule never reaches the mock at all. Mutation-checked — discarding the
rewritten body fails two of them.

The startup lines claimed a match was refused while redaction was on. They now
say what is actually configured, including which rules are exceptions.

Closes #102

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@CharmingGroot
CharmingGroot merged commit 9c863af into main Sep 1, 2026
5 checks passed
@CharmingGroot
CharmingGroot deleted the feat/redaction branch September 1, 2026 03:08
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.

Blocking is the only response to a finding; masking is the one people actually deploy

1 participant