tork-governance 0.2.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 216554d503f29e742fd17648fe812add23b32f509c5af2a3df6620ff278e0c84
4
- data.tar.gz: 3389e8d1f22910eed0a302963837a152bf541303690938d816ef7257b31be926
3
+ metadata.gz: 963fab3e6129b3371a40e11682ea3d87832a0040b360175046fb702dae7e31d8
4
+ data.tar.gz: 6e9fd5e7cca5c8c54587246ecd3aecdab617968178b158e3ed0f548ca034cebf
5
5
  SHA512:
6
- metadata.gz: 8943faa1f06adf99915b6d3d2080a4890b9b42d399ad3e2464cd3e5a4caf90e58c88b730a59491caefe8672dcacc9fd54ed7afb8a5d38b8c3631a99da72f1942
7
- data.tar.gz: 284b06a25065b00b0eddb0e581f376d15a097d58adfddeefab49eb2e579cc63593e85d2d991121e13ee89d461f564a48d2d1f147c59a67b75f6e8d55fa221821
6
+ metadata.gz: 0e52006b757b95ee64813d40e572a1f46de079af72e71735a76ac93a515abd6a6afc12f72ba4e8ae45eb45ca06c62fbbc8ff556a51f725b8c164ba00c79467ed
7
+ data.tar.gz: 4fba63c3b41717c4d45c4c1553c899bab508168223ae8cf6963a31da4558dfeb0f94f973cec45b69ec4b9ddff30c4b6a744d74fd556e1e581b58440e67b90114
data/CHANGELOG.md ADDED
@@ -0,0 +1,107 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0 - 2026-09-25
4
+
5
+ ### Added
6
+ - **PII registry bundle 1.2.0 (24 countries, incl. AU TFN/ABN/Medicare).**
7
+ - **The country layer: 24 country profiles, 54 patterns, 20 check digits.**
8
+ Patterns, keywords, redaction labels and checksum gates are generated from
9
+ Tork's own country registry and consumed verbatim from the SDK bundle
10
+ (`Registry-Version: 1.2.0`, content `cfd4f61ebaf45e74`). Countries: AU, US, GB, EU, AE, SA, NG, IN, JP,
11
+ CN, KR, BR, CA, ZA, GH, IT, KE, MU, MX, MY, PK, SG, TH, ID.
12
+ - **Rule 1a: `au_tfn`, `au_abn` and `au_medicare` now run on every document,**
13
+ not just when Australia activates -- the ABN's own corpus sentence
14
+ activates no region at all. `au_tfn` and `au_abn` gate on a required
15
+ checksum (a failing TFN falls back to a generic near-miss redaction; a
16
+ failing ABN simply does not match); `au_medicare`'s checksum is advisory
17
+ only and never blocks a match. Closes the bundle 1.1.0 gap where
18
+ `checksums.json` named these three but `patterns` shipped none of them.
19
+ - New namespace `Tork::Governance::Pii`: `PATTERNS` (the bundle's 54 patterns),
20
+ `SIGNALS` and `COUNTRY_PATTERNS` (the 51 activation signals and the country
21
+ map), `Checksums` (20 algorithms) and `Country` (the matcher). All pure and
22
+ local: no network, no clock.
23
+ - `PIIResult` gains `country_matches`, `country_labels` and `regions`, all
24
+ defaulting to empty so existing construction still works.
25
+ `PIIDetector.detect(text, regions)` forces a set of country profiles on; the
26
+ single-argument form is unchanged.
27
+ - **Nine check digits ported by hand.** The bundle names twenty algorithms and
28
+ specifies the eleven that reduce to a weight vector and a modulus; the other
29
+ nine (`br_cpf`, `br_cnpj`, `cn_resident_id`, `de_steuer_id`, `fr_nir`,
30
+ `it_codice_fiscale`, `jp_my_number`, `kr_rrn`, `sg_nric`) are ported from the
31
+ cloud's `lib/pii/checksums.ts`, each tested against the issuing authority's
32
+ own worked example where one is published.
33
+
34
+ ### Fixed
35
+ - **SDK-RUBY-PARTIAL-REDACTION.** Until 0.3.0 each type was redacted with its
36
+ own `gsub` over text a previous type had already rewritten, while `matches`
37
+ carried indices into the *original* text. Two types matching overlapping
38
+ spans could leave half an identifier standing beside a redaction token --
39
+ digits exposed in output the caller had been told was redacted. Every match
40
+ is now collected against the original text, overlaps are resolved before
41
+ anything is rewritten, and the surviving spans are spliced right to left in
42
+ one pass. `no partial redaction` asserts the invariant across all 268
43
+ vectors.
44
+ - **The gemspec read the wrong version constant.** `tork-governance.gemspec`
45
+ took `spec.version` from `lib/tork/version.rb` -- `Tork::VERSION`, which
46
+ belongs to the separate `Tork` API client that also lives in this repository.
47
+ It now reads `TorkGovernance::VERSION` from
48
+ `lib/tork_governance/version.rb`. The two happened to agree; nothing kept
49
+ them in step, so a bump to one would have shipped the other's number.
50
+
51
+ ### Notes
52
+ - This release folds in 0.3.0, which is in this repository but was never
53
+ published to RubyGems (RubyGems is at 0.2.0).
54
+ - **`bundle exec rspec` does not run green, for a reason that predates this
55
+ release and is untouched by it.** `spec/tork_spec.rb` covers the separate
56
+ `Tork` API client and needs a `stub_tork_request` helper that exists nowhere
57
+ in the repository, and `spec/spec_helper.rb` never requires `lib/tork`, so
58
+ the whole run aborts with "uninitialized constant Tork" before any example.
59
+ The governance suite -- `spec/tork_governance_spec.rb`,
60
+ `spec/pii_type_parity_spec.rb`, `spec/tool_result_scan_spec.rb` and the new
61
+ `spec/pii_country_spec.rb` -- runs green at 464 examples, 0 failures. Fixing
62
+ the orphaned spec means adding the missing helper (WebMock is already a
63
+ development dependency) and is a separate piece of work.
64
+ - **`PIIMatch#value` still carries the RAW matched value here**, where every
65
+ other Tork SDK stores the literal `"[REDACTED]"` so a caller cannot log the
66
+ sensitive value straight out of a detection result. It is asserted by
67
+ `spec/tork_governance_spec.rb:166`, so changing it is a deliberate breaking
68
+ change rather than something to slip into a minor. Flagged, not changed.
69
+ - **The bundle now states the whole contract, and this SDK implements it.**
70
+ Bundle 1.0.0's README documented three rules; measured against the cloud's
71
+ golden snapshot they disagreed with it on 14 of 86 country-corpus cases, so
72
+ this SDK carried two more of its own. Bundle **1.1.0 documents seven**, marks
73
+ each SDK or cloud-only, and ships the data all seven need in every language
74
+ file -- the activation signals, the country map, the asymmetric 60/40 window,
75
+ the symmetric 60 context window, the whole-word vocabulary, the near-miss
76
+ policy, the table constants and the reference labels. So the locally generated
77
+ activation layer is **deleted**, no window is hard-coded any more, and rules 6
78
+ (near miss), 7 (column header) and 7b (nearest label) are implemented here for
79
+ the first time. Every rule now reads its data off the placed bundle.
80
+ - Advisory checksums never reject a match: `ca_sin`, `emirates_id`,
81
+ `de_tax_id`, `kr_rrn`, `sa_national_id`. Korea stopped issuing check digits on
82
+ 20 Oct 2020.
83
+ - Not ported, and still cloud-only: the slot, context,
84
+ gravity and name layers, industry profiles, and org configuration.
85
+ - **Indonesia is the country 1.1.0 added, and it is the one that proves the
86
+ whole-word rule.** `id_nik`'s only short spellings -- NIK, KTP, NPWP -- are
87
+ `wholeWordKeywords`, not ordinary keywords, because `nik` sits inside
88
+ *teknik*, *elektronik*, *klinik* and *pabrik*. Matching them by substring
89
+ would open the gate on an Indonesian sales ledger; matching them on a word
90
+ boundary catches "NIK 3171010101900001" and leaves *teknik* alone. An SDK that
91
+ merged the two lists would be shipping a false-positive bug, so the boundary
92
+ test is implemented rather than the shortcut, and four unit cases assert both
93
+ halves.
94
+ - **FLAGGED, upstream: bundle 1.1.0 cannot detect Australia's TFN, ABN or
95
+ Medicare number.** `checksums.json` declares `au_tfn` and `au_abn` as
96
+ `requiredBy` and `au_medicare` as `advisoryFor` patterns of those names, and
97
+ `patterns` ships none of them -- the AU profile carries only `au_acn` and
98
+ `au_phone_intl`. The AU activation signals are still keyed on "tfn", "tax
99
+ file" and "medicare", so the bundle switches Australia on for identifiers it
100
+ then has no pattern to catch. The cloud detects all three. This is a recall
101
+ gap no SDK can close from the bundle, and the six parity cases it costs are
102
+ recorded in the fixture as `BUNDLE GAP` rather than silently accepted.
103
+
104
+ ## 0.2.2 - 2026-03-09
105
+
106
+ ### Added
107
+ - feat: agent/session context fields (agent_id, agent_role, session_id, session_turn)
data/README.md CHANGED
@@ -34,6 +34,89 @@ puts result.pii.types # ['ssn', 'email']
34
34
  puts result.receipt.id # Cryptographic receipt ID
35
35
  ```
36
36
 
37
+ ## Country PII detection
38
+
39
+ 23 country profiles, 50 patterns and 20 check digits, generated from Tork's own
40
+ country registry (bundle `1.0.0`) and computed entirely on-device.
41
+
42
+ Countries: AU, US, GB, EU, AE, SA, NG, IN, JP, CN, KR, BR, CA, ZA, GH, IT, KE,
43
+ MU, MX, MY, PK, SG, TH.
44
+
45
+ A country's patterns switch on when the text activates that country — the same
46
+ content signals the cloud uses — so ordinary business text is not measured
47
+ against 50 national-identifier patterns it could never contain. On the
48
+ 1,159-line business corpus this SDK is tested against, nothing is redacted.
49
+
50
+ ```ruby
51
+ r = TorkGovernance::PIIDetector.detect(
52
+ 'South African ID number 8001015009087 for the FICA check.'
53
+ )
54
+
55
+ r.regions # ["ZA"]
56
+ r.country_labels # ["ZA_ID"]
57
+ r.redacted_text # "South African ID number [ZA_ID_REDACTED] for the FICA check."
58
+ ```
59
+
60
+ Force profiles on when you already know the jurisdiction:
61
+
62
+ ```ruby
63
+ forced = TorkGovernance::PIIDetector.detect(
64
+ 'Documento 529.982.247-25 arquivado.', ['br']
65
+ )
66
+ # forced.redacted_text == "Documento [CPF_REDACTED] arquivado."
67
+ ```
68
+
69
+ Three gates keep the false-positive rate down, and all three must pass:
70
+
71
+ 1. **Activation** — one of the country's content signals fires.
72
+ 2. **Keyword** — for 18 of the 24 national, tax and health identifiers, one of
73
+ the identifier's keywords must appear within 60 characters before the match
74
+ or 40 after.
75
+ 3. **Check digit** — for the 10 identifiers whose issuing authority publishes
76
+ the algorithm, a number of the right shape that fails its check digit is not
77
+ that country's identifier. Where the algorithm is community-sourced rather
78
+ than authority-published (`ca_sin`, `emirates_id`, `de_tax_id`, `kr_rrn`,
79
+ `sa_national_id`) the checksum is advisory and never rejects a match.
80
+
81
+ Still cloud-only, and not in this SDK: the near-miss fallback, the slot,
82
+ context, gravity and name layers, industry profiles, and org configuration.
83
+
84
+ ## Scanning tool results
85
+
86
+ A tool result returned by an MCP server — or any external system you do not control — is untrusted input that is about to be appended to a model's context. `TorkGovernance::ToolResultScan.scan_tool_result` scans it first, on-device, for PII and prompt injection:
87
+
88
+ ```ruby
89
+ require 'tork_governance'
90
+
91
+ tork = TorkGovernance::Client.new
92
+ scan = tork.scan_tool_result(
93
+ tool_name: 'lookup_customer',
94
+ server_uri: 'mcp://crm.internal/customers',
95
+ payload: tool_result, # whatever the server returned
96
+ block_on_injection: true
97
+ )
98
+
99
+ if scan.blocked
100
+ warn(scan.reason) # do not append anything
101
+ else
102
+ append_to_context(scan.sanitized) # PII masked in place
103
+ end
104
+
105
+ scan.findings
106
+ # [#<struct TorkGovernance::ToolResultScan::ToolResultFinding kind="pii", type="email", count=1, location="$.content[0].text">,
107
+ # #<struct ... kind="injection", type="heuristic:instruction_override", count=1, location="$.content[0].text">]
108
+ ```
109
+
110
+ There is also a standalone `TorkGovernance::ToolResultScan.scan_tool_result(tool_name:, payload:, ...)` module method with the same keyword arguments that returns `sanitized`/`findings`/`blocked`/`reason` and produces no receipt.
111
+
112
+ - **PII uses the same on-device detector as `govern`** — same patterns, same redaction labels. Matches are masked in place; the payload structure is otherwise unchanged, and a clean payload comes back untouched (`equal?` its input).
113
+ - **Injection detection is heuristic.** A conservative pattern set (`tork-injection-heuristics-v1`) covering instruction-override phrases, role reassignment, and exfiltration URLs. Every injection finding is typed `heuristic:<name>` because that is exactly what it is: a regex match over untrusted text, with false positives and false negatives, not a verified determination. Without `block_on_injection`, matches are reported and the result is still returned; with it, `sanitized` is `nil` so no masked copy can be appended by accident.
114
+ - **Zero network calls.** The scan is pure and synchronous. The payload never leaves the machine.
115
+ - **Recorded on the receipt as counts only.** `receipt.tool_result_scan` carries `attested_by: 'client'`, `capture_mode: 'edge'`, the tool name and server URI, counts by kind and type, the blocked flag, and the SDK version. It never carries the payload, a matched value, or a location path.
116
+ - **PII parity tier: TIER 1.** This SDK detects the same 10-type basic vocabulary as the JS SDK (`ssn`, `credit_card`, `email`, `phone`, `address`, `ip_address`, `date_of_birth`, `passport`, `drivers_license`, `bank_account`), with JS-identical type labels. It does **not** implement the Python SDK's regional/industry pattern tier — there is no `region:`/`industry:` support in `scan_tool_result`.
117
+
118
+ **This is a client-side, client-attested control.** The scan runs in your process, and the receipt says so: Tork did not execute it and cannot verify it ran at all. **Gateway-side enforcement, where a caller cannot skip the scan, is a separate and later control.** Do not read a `tool_result_scan` block as proof that every tool result reaching a model was scanned; read it as a record of the scans a caller chose to run.
119
+
37
120
  ## Supported Frameworks (2 Adapters)
38
121
 
39
122
  ### Web Frameworks
@@ -116,14 +199,22 @@ TorkGovernance.configure(
116
199
 
117
200
  ## PII Detection
118
201
 
119
- Detects 50+ PII types including:
120
-
121
- | Category | Types |
122
- |----------|-------|
123
- | **US** | SSN, EIN, ITIN, Passport, Driver's License |
124
- | **Australia** | TFN, ABN, ACN, Medicare |
125
- | **Financial** | Credit Card, Bank Account, SWIFT/BIC |
126
- | **Universal** | Email, IP Address, URL, Phone, DOB |
202
+ Detects the 10-type Tier 1 basic vocabulary, with labels identical to the JS SDK's Tier 1 tier:
203
+
204
+ | Type | Label |
205
+ |------|-------|
206
+ | SSN | `ssn` |
207
+ | Credit Card | `credit_card` |
208
+ | Email | `email` |
209
+ | Phone | `phone` |
210
+ | Address | `address` |
211
+ | IP Address | `ip_address` |
212
+ | Date of Birth | `date_of_birth` |
213
+ | Passport | `passport` |
214
+ | Driver's License | `drivers_license` |
215
+ | Bank Account | `bank_account` |
216
+
217
+ This SDK does not implement region-specific (e.g. AU TFN/ABN/ACN/Medicare, US EIN/ITIN, SWIFT/BIC) or industry-specific patterns — that is the Python SDK's regional tier, not this one.
127
218
 
128
219
  ## Documentation
129
220
 
data/lib/tork/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tork
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -3,13 +3,16 @@
3
3
  module TorkGovernance
4
4
  # Governance result
5
5
  class GovernResult
6
- attr_reader :action, :output, :pii, :receipt
6
+ attr_reader :action, :output, :pii, :receipt, :region, :industry, :session_context
7
7
 
8
- def initialize(action:, output:, pii:, receipt:)
8
+ def initialize(action:, output:, pii:, receipt:, region: nil, industry: nil, session_context: nil)
9
9
  @action = action
10
10
  @output = output
11
11
  @pii = pii
12
12
  @receipt = receipt
13
+ @region = region
14
+ @industry = industry
15
+ @session_context = session_context
13
16
  end
14
17
 
15
18
  def allowed?
@@ -38,6 +41,24 @@ module TorkGovernance
38
41
  end
39
42
  end
40
43
 
44
+ # What Client#scan_tool_result returns: the pure scan result (sanitized,
45
+ # findings, blocked, reason -- exactly the shape of
46
+ # ToolResultScan.scan_tool_result's own return value, so either form can
47
+ # be consumed by the same code), plus the receipt recording it.
48
+ class GovernedToolResultScanResult
49
+ attr_reader :sanitized, :findings, :blocked, :reason, :receipt
50
+
51
+ def initialize(sanitized:, findings:, blocked:, receipt:, reason: nil)
52
+ @sanitized = sanitized
53
+ @findings = findings
54
+ @blocked = blocked
55
+ @reason = reason
56
+ @receipt = receipt
57
+ end
58
+
59
+ alias blocked? blocked
60
+ end
61
+
41
62
  # Main Tork governance client
42
63
  class Client
43
64
  attr_reader :api_key, :policy_version, :default_action, :stats
@@ -57,6 +78,12 @@ module TorkGovernance
57
78
  # Apply governance to content
58
79
  #
59
80
  # @param input [String] the content to govern
81
+ # @param region [Array<String>, nil] optional regional PII profiles (e.g. ["ae", "in"])
82
+ # @param industry [String, nil] optional industry profile (e.g. "healthcare", "finance", "legal")
83
+ # @param agent_id [String, nil] identifier for the agent making the call
84
+ # @param agent_role [String, nil] role of the agent: "planner", "worker", or "judge"
85
+ # @param session_id [String, nil] groups all calls from the same agent session
86
+ # @param session_turn [Integer, nil] position in the conversation (1, 2, 3...)
60
87
  # @return [GovernResult] the governance result
61
88
  #
62
89
  # @example
@@ -64,7 +91,7 @@ module TorkGovernance
64
91
  # result = client.govern("My email is test@example.com")
65
92
  # puts result.output # "My email is [EMAIL_REDACTED]"
66
93
  # puts result.receipt.id # "rcpt_..."
67
- def govern(input)
94
+ def govern(input, region: nil, industry: nil, agent_id: nil, agent_role: nil, session_id: nil, session_turn: nil)
68
95
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
69
96
 
70
97
  # Detect PII
@@ -98,10 +125,124 @@ module TorkGovernance
98
125
  @stats[:total_processing_ns] += processing_time_ns
99
126
  @stats[:action_counts][action] += 1
100
127
 
128
+ # Build session context if any agent/session fields are provided
129
+ session_context = nil
130
+ if agent_id || agent_role || session_id || session_turn
131
+ session_context = {
132
+ agent_id: agent_id,
133
+ agent_role: agent_role,
134
+ session_id: session_id,
135
+ session_turn: session_turn
136
+ }.compact
137
+ end
138
+
101
139
  GovernResult.new(
102
140
  action: action,
103
141
  output: output,
104
142
  pii: pii,
143
+ receipt: receipt,
144
+ region: region,
145
+ industry: industry,
146
+ session_context: session_context
147
+ )
148
+ end
149
+
150
+ # Scan a tool result (MCP server response, or any external system's
151
+ # output) for PII and prompt injection BEFORE it is appended to model
152
+ # context, and record the scan on a receipt.
153
+ #
154
+ # The scan itself is the pure ToolResultScan.scan_tool_result -- on-device,
155
+ # synchronous, zero network calls, using the same PII detector as
156
+ # #govern. This method adds the receipt: `receipt.tool_result_scan`
157
+ # carries counts by kind and type, the tool name, the server URI,
158
+ # whether the result was blocked, and the SDK version. It never carries
159
+ # the payload, a matched substring, or a location path.
160
+ #
161
+ # This is a CLIENT-SIDE, CLIENT-ATTESTED control: it runs in the
162
+ # caller's process, so the receipt records `attested_by: 'client'` and
163
+ # `capture_mode: 'edge'` -- Tork did not execute this scan and cannot
164
+ # verify it ran at all.
165
+ #
166
+ # @param tool_name [String] name of the tool that produced this result
167
+ # @param payload [Object] the tool result itself (any JSON-shaped value); never leaves the machine
168
+ # @param server_uri [String, nil] URI of the MCP server (or other origin), recorded when present
169
+ # @param block_on_injection [Boolean] block the result when injection heuristics fire (default false)
170
+ # @param custom_patterns [Hash{String,Symbol=>Regexp}, nil] extra redaction patterns
171
+ # @param max_depth [Integer] maximum nesting depth to walk
172
+ # @return [GovernedToolResultScanResult]
173
+ def scan_tool_result(
174
+ tool_name:,
175
+ payload:,
176
+ server_uri: nil,
177
+ block_on_injection: false,
178
+ custom_patterns: nil,
179
+ max_depth: ToolResultScan::DEFAULT_MAX_DEPTH
180
+ )
181
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
182
+
183
+ scan = ToolResultScan.scan_tool_result(
184
+ tool_name: tool_name,
185
+ payload: payload,
186
+ server_uri: server_uri,
187
+ block_on_injection: block_on_injection,
188
+ custom_patterns: custom_patterns,
189
+ max_depth: max_depth
190
+ )
191
+
192
+ pii_types = ToolResultScan.scan_pii_types(scan.findings)
193
+ pii_count = ToolResultScan.scan_pii_count(scan.findings)
194
+ injection_count = ToolResultScan.scan_injection_count(scan.findings)
195
+
196
+ # Fixed mapping, deliberately NOT default_action: unlike #govern, this
197
+ # path always returns masked output when it returns any, so the
198
+ # action must describe what actually happened to the tool result.
199
+ # Every SDK mirroring this must use the same mapping.
200
+ # blocked -> deny (nothing is returned to append)
201
+ # injection detected -> escalate (returned, flagged for a human)
202
+ # PII masked -> redact
203
+ # nothing found -> allow
204
+ action =
205
+ if scan.blocked
206
+ ACTIONS[:deny]
207
+ elsif injection_count.positive?
208
+ ACTIONS[:escalate]
209
+ elsif pii_count.positive?
210
+ ACTIONS[:redact]
211
+ else
212
+ ACTIONS[:allow]
213
+ end
214
+
215
+ processing_time_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - start_time
216
+
217
+ # Hashes, not content: hash_text is SHA256, so neither the payload nor
218
+ # the sanitized copy is recoverable from the receipt. A blocked scan
219
+ # has no output to hash and records the hash of the empty string.
220
+ receipt = Receipt.generate(
221
+ input: ToolResultScan.stable_stringify(payload),
222
+ output: scan.blocked ? "" : ToolResultScan.stable_stringify(scan.sanitized),
223
+ action: action,
224
+ pii_types: pii_types,
225
+ pii_count: pii_count,
226
+ policy_version: policy_version,
227
+ processing_time_ns: processing_time_ns,
228
+ tool_result_scan: ToolResultScan.build_tool_result_scan_block(
229
+ tool_name: tool_name,
230
+ server_uri: server_uri,
231
+ result: scan,
232
+ sdk_version: TorkGovernance::VERSION
233
+ )
234
+ )
235
+
236
+ @stats[:total_calls] += 1
237
+ @stats[:total_pii_detected] += 1 if pii_count.positive?
238
+ @stats[:total_processing_ns] += processing_time_ns
239
+ @stats[:action_counts][action] += 1
240
+
241
+ GovernedToolResultScanResult.new(
242
+ sanitized: scan.sanitized,
243
+ findings: scan.findings,
244
+ blocked: scan.blocked,
245
+ reason: scan.reason,
105
246
  receipt: receipt
106
247
  )
107
248
  end