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 +4 -4
- data/CHANGELOG.md +107 -0
- data/README.md +99 -8
- data/lib/tork/version.rb +1 -1
- data/lib/tork_governance/client.rb +144 -3
- data/lib/tork_governance/pii/checksums.rb +359 -0
- data/lib/tork_governance/pii/country.rb +437 -0
- data/lib/tork_governance/pii/registry.rb +1605 -0
- data/lib/tork_governance/pii.rb +123 -20
- data/lib/tork_governance/receipt.rb +14 -5
- data/lib/tork_governance/tool_result_scan.rb +410 -0
- data/lib/tork_governance/version.rb +1 -3
- data/lib/tork_governance.rb +13 -0
- data/tork-governance.gemspec +6 -2
- metadata +8 -7
- data/tork_governance.gemspec +0 -43
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 963fab3e6129b3371a40e11682ea3d87832a0040b360175046fb702dae7e31d8
|
|
4
|
+
data.tar.gz: 6e9fd5e7cca5c8c54587246ecd3aecdab617968178b158e3ed0f548ca034cebf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
|
122
|
-
|
|
123
|
-
|
|
|
124
|
-
|
|
|
125
|
-
|
|
|
126
|
-
|
|
|
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
|
@@ -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
|