Data Shield: strict data-safety mode (design preview)
Source:vignettes/data-shield.Rmd
data-shield.RmdStatus — P0/P0.5/P1/P1.5/C2/C5-policy available; fuller design in progress. The egress row-cap, protected-value matching, strict DescribeData, ordered scanners, universal pre-tool ingress, per-tool policy, promise-backed egress approval, portable path/symlink sandbox, optional sanitized-code reviewer, and DP-noised categorical counts (
distributions="dp") are wired. Full OS execution adapter and differential privacy for numeric/continuous statistics remain roadmap. Off by default (data_shield = NULL).
Why
When codeagent is embedded as a backend over sensitive
data (clinical, financial, PII), the guarantee we want is: the
LLM may see metadata and descriptive summaries, but
never raw row-level data. Data Shield is an
opt-in, pluggable valve that enforces this — off by
default (data_shield = NULL), composed from independent
strategies when on.
The core: three edges
Strip away the agent machinery and data crosses the model boundary at three edges. Guard all three and you have guarded everything:
- Prompt / system-prompt content (edge 1, input gate) — including the framework’s own ambient-context auto-injection (codeagent can inject a summary of R session objects into the system reminder). This is the part we control and must keep schema-only (names / types / dimensions), never values.
- Tool results (edge 2, tool gate) — whatever a tool returns and that is fed back to the model.
- The model’s final reply (edge 3, output gate) — scanned before it reaches the user, since the model may reproduce a protected value it inferred from an edge-2 aggregate even when the user’s own input was clean.
Everything else reduces to these (RAG and errors arrive as one of them). The guarantee applies recursively to sub-agents — each has its own edges.
Blind spot to handle separately: an image/multimodal tool result (a rendered table/plot of raw rows) bypasses text scanning. An OCR image scanner is available opt-in (
data_shield_ocr_scanner()) to close edge 1 for text baked into uploaded images.
Architecture at a glance
codeagent's central gate (sole authority, existing dual hooks)
Host/user uploads data
│ shield$register_data(df, name, sensitivity)
▼
┌────────────────────┐ builds a value index (deduped high-entropy values from
│ Protected data │ sensitive columns, LOCAL ONLY, never sent to the model)
│ registration │──────────────────────────────────────────┐
│ bound to an envir / │ │
│ auto-classified │ │
└──────────┬──────────┘ │
│ the only sanctioned way to feed the model │
▼ ▼
DescribeData (C6) value_match lookup
hardened schema + stats + k-anonymity (small output × prebuilt index = fast)
│ │
┌──────┴──────── the two entry points into model context (both must be guarded) ────┴──────┐
│ ① prompt injection ◄── ambient context ◄── only a safe profile is fed │
│ ② tool results: │
│ on_tool_request ─►[ ingress pipeline: C2 blacklist → C4 review (exec) ]─► execute ─► output
│ output ─► on_tool_result ─►[ egress pipeline: C3 row_cap → value_match → regex → C4 review ]─► fed back to the model
│ per-strategy on_fail: pass|redact|block|ask; sequential execution + fail_fast │
└───────────────────────────────────────────────────────────────────────────────────────────┘
(C5 sandbox: exec no-network/read-only/separate user; C1 narrowing: optionally remove risky
tools — both are enhancements, not the boundary)
Setup used by every snippet below
Every code block in this vignette continues from a chat
(a bare ellmer Chat, not the
CodeagentClient wrapper) and a shield (a
DataShield instance) built once, up front:
chat <- ellmer::chat_openai_compatible(
base_url = Sys.getenv("CODEAGENT_BASE_URL"),
model = Sys.getenv("CODEAGENT_MODEL"),
credentials = function() Sys.getenv("CODEAGENT_API_KEY"))
shield <- DataShield$new()codeagent_client(chat, ...) does not
clone chat — client$chat is the exact same
object you passed in (mutable R6/environment semantics), so a snippet
may call either chat$register_tool(...) or
client$chat$register_tool(...) interchangeably; this
vignette uses the bare chat variable throughout for
brevity.
Design: composable strategies (not a fixed mode)
data_shield accepts NULL (off), an ordered
strategy list (creates one private DataShield R6), or an
explicit DataShield instance (for upload and intentional
session/thread sharing). Implemented strategies are shown first; planned
strategies are listed separately so the examples never imply they
already exist.
# Implemented now:
client <- codeagent_client(chat, data_shield = list(
shield_describe(k_anon = 5),
shield_egress(detectors = c("row_cap", "value_match"),
max_rows = 0, on_fail = "block"),
shield_regex(on_fail = "redact"),
shield_ingress(langs = c("r", "python", "bash"), on_fail = "ask"),
shield_tool_policy(rules = list(
KMPlot = list(ingress = "scan", egress = "bypass"),
DangerousExport = list(execution = "deny")
)),
shield_sandbox(project_root = getwd(), backend = "policy"),
shield_reviewer(model = Sys.getenv("CODEAGENT_FAST_MODEL"),
scope = c("exec", "write", "net"), on_risk = "ask")
))
# Roadmap (not implemented yet):
# shield_narrow_tools()The main boundary is edge 2 (tool results); sandbox, ingress blacklist, reviewer, and tool narrowing are defense-in-depth, not the boundary.
Current parameter reference
data_shield input
| Value | Effect |
|---|---|
NULL |
Shield completely off; existing codeagent behaviour unchanged |
list(shield_*()) |
Strategies run in list order; codeagent creates one private
DataShield R6 for this client |
DataShield$new(...) |
Explicit lifecycle for uploads and deliberate sharing among selected chats |
shield_egress() — core tool-result boundary
| Parameter | Default | Actual effect |
|---|---|---|
detectors |
c("row_cap", "value_match") |
row_cap catches bulk tabular output;
value_match catches indexed high-entropy values |
max_rows |
0 |
On a bulk/tabular match, retain zero raw printed lines and return
only a withheld/blocked notice. 5 would deliberately expose
the first five printed lines |
on_fail |
"redact" |
redact: withheld notice; block: blocked
notice; ask: pause before LLM delivery |
allow_raw_approval |
FALSE |
When asking, show only Redact/Block; TRUE adds dangerous Raw once |
approval_timeout |
60 |
Async seconds before automatic redact |
max_rows = 0 does not block
print() generally. print(nrow(df)), status
messages, model summaries, plots, and errors pass unless another
detector finds sensitive content. It triggers only when output has a
data.frame/tibble or many-line rectangular-table shape.
With on_fail="ask", the raw result remains local while
the callback/UI receives only tool name/id, strategy, reason label,
match count, score, timeout and whether raw-once is enabled. Redact is
the safe default for no callback, errors, invalid choices, ESC and
timeout. Raw-once applies to one result only and is audited.
shield_describe() — strict model-safe metadata
| Parameter | Default | Actual effect |
|---|---|---|
distributions |
"off" |
"off": no counts, category labels only.
"on": real per-category counts (no privacy protection).
"dp": Laplace-noised per-category counts, spending
dp_epsilon from a per-dataset dp_budget;
degrades to "off"-style labels once the budget is
exhausted. Numeric/date/logical/free-text columns are unchanged
in all three modes — see below |
k_anon |
5 |
Category labels supported by fewer than k rows become
<rare suppressed> (applies in all three
distributions modes, before any count is added) |
category_max |
20 |
Maximum distinct character values for categorical treatment |
category_ratio |
0.2 |
Maximum distinct/non-missing ratio for character-categorical
treatment; otherwise free_text
|
dp_epsilon |
1 |
Privacy cost charged per describe()/schema-block call
for each categorical column exposed under "dp"; split
across that column’s surviving categories. Only meaningful with
"dp"
|
dp_budget |
5 |
Total per-dataset epsilon budget under "dp" — a
one-time allowance, no time-window reset. Only meaningful with
"dp"
|
Sensitivity still clamps output:
identifier/quasi values stay suppressed;
measure/open may show numeric/date min–max and
safe category labels, with counts added under
"on"/"dp".
Scope limit (v1): only categorical columns get
counts. Numeric columns (mean/sum/quantiles) are not covered —
differentially-private release of a continuous statistic needs a
clipping bound to calibrate its noise, and that bound must not
be derived from the private data’s own min/max (doing so would leak
privacy strength from the private data itself, a well-known DP pitfall).
Until a host can supply a real, data-independent bound per numeric
column, range=[min, max] is shown unchanged in all three
distributions modes. Logical and free-text columns are
likewise unchanged. Track:
references/plan/31x-dp-distributions.TODO.md.
Exhausting a dataset’s dp_budget is silent and permanent
for that dataset’s lifetime (until it is re-registered): no error, no
raw count, just a quiet drop back to "off"-style
labels-only output. Call shield$dp_budget_remaining(name)
(or with no argument, a named vector for every registered dataset) to
check remaining budget, e.g. for a host-side “privacy budget: 2/5
remaining” indicator. Every consumption/exhaustion is also recorded in
the audit log (strategy = "dp_budget").
dp_metadata <- shield_describe(distributions = "dp", dp_epsilon = 1, dp_budget = 5)
shield <- DataShield$new(strategies = list(dp_metadata))
shield$register_data(df, name = "study")
shield$describe("study") # spends 1 epsilon if "arm" is categorical
shield$dp_budget_remaining("study") # 4
shield_regex() — unregistered PII/secrets
| Parameter | Default | Actual effect |
|---|---|---|
patterns |
NULL |
Optional named regular-expression rules using PCRE (Perl-Compatible
Regular Expression) syntax,
e.g. c(study_id = "STUDY-[0-9]+"); appended to defaults
when enabled |
include_defaults |
TRUE |
Email, phone-like, common-token-prefix, and 18-character identity-number rules |
replacement |
"[REDACTED]" |
Marker inserted once per merged matching span |
on_fail |
"redact" |
redact: preserve safe surrounding text;
block: replace the whole result |
ignore_case |
TRUE |
Case-insensitive matching for all rules |
shield_ingress() — scan every tool call before
execution
| Parameter | Default | Actual effect |
|---|---|---|
langs |
c("r", "python", "bash") |
Select built-in rules for each code/shell language |
patterns |
NULL |
Named regex rules; a name matching a built-in replaces that rule, a new name is added. Host file-based blacklists read their own file into a named vector and pass it here |
include_defaults |
TRUE |
Include the built-in per-language rule set
(.DATA_SHIELD_INGRESS_RULES): serialization/encoding,
pandas/R writers, network transfer
(incl. nc/scp//dev/tcp),
data-file display, and protected-name preview |
on_fail |
"block" |
block: reject the tool call; ask: force
the existing permission approval UI/callback |
ignore_case |
TRUE |
Case-insensitive matching |
Ingress scans all tool arguments before the usual read/write/exec capability fast paths, including unknown and read-only tools. It does not ban ordinary reading: defaults focus on high-confidence read-and-display, serialization, encoding, and network-transfer patterns. It is a cheap pre-filter; egress scanning remains the primary boundary because code can be obfuscated.
shield_tool_policy() — exact/glob trust and deny
rules
| Setting | Meaning |
|---|---|
default="scan" |
Every tool is scanned unless a rule overrides it |
execution="deny" |
Reject the tool before execution |
ingress="bypass" |
Skip Shield argument scanning, but still apply permission gate |
egress="bypass" |
Return that tool’s output without Shield filtering; audit every bypass |
egress="deny" |
Replace the result with an explicit policy-denied notice |
Rules support exact names and * globs. Exact wins;
otherwise the first matching glob wins. For example, KMPlot
may bypass egress when its developer guarantees all outputs are
LLM-safe, while btw_tool_docs_* can receive a broader
trusted rule. This policy never bypasses codeagent’s independent
permission system.
shield_sandbox() — portable containment without
crippling the agent
| Parameter | Default | Actual effect |
|---|---|---|
project_root |
getwd() |
Project root allowed by path policy |
protected_paths |
none | Extra registered data roots; longest matching root controls mode |
temp_root |
new session temp | Isolated temporary root |
modes |
project rwx, data rw, temp
rwx
|
Logical Shield capabilities (not chmod bits) |
process_exec |
TRUE |
Preserve RunR/Bash/Python; FALSE blocks exec tools |
network |
"tool_policy" |
Let tool policy decide; "deny" blocks net
capability |
symlink_escape |
"deny" |
Resolve real paths and reject links escaping allowed roots |
backend |
"auto" |
policy, auto, or
required
|
on_unavailable |
"policy" |
Full OS adapter unavailable → policy fallback; block
fails closed for exec/net |
Current implementation is a portable central-gate path/capability
policy. It is not advertised as kernel isolation: the capability probe
found user/network/ mount namespaces but no bubblewrap/container, and
plain unshare still sees the host filesystem. A future full
adapter must move exec tools out of process.
shield_reviewer() — optional sanitized-code semantic
rail
| Parameter | Default | Actual effect |
|---|---|---|
client_factory |
NULL |
Optional function returning a fresh independent ellmer Chat |
model |
CODEAGENT_FAST_MODEL |
Reviewer model; missing config follows on_error, never
silently main model |
scope |
exec/write/net | Only these tool capabilities incur review cost |
on_risk |
"ask" |
Risk classification becomes ask or block |
on_error |
"ask" |
Missing model, timeout, request/JSON errors become ask or block; no approval→block |
backend |
"remote_sanitized" |
Remote sees only regex/value-sanitized code; raw egress review is local-only roadmap |
timeout |
30 |
Async review timeout seconds |
The reviewer is not a tool and cannot be skipped by the main model.
It has no tools or history; code is delimited as untrusted data and
parsed from fixed JSON (risk, confidence,
reason). Deterministic ingress rules run first, so
already-blocked calls incur no model cost.
DataShield$new() direct lifecycle
Direct constructor parameters (max_rows,
distributions, k_anon,
category_max, category_ratio,
audit_max) create the default DescribeData + core egress
configuration when strategies = NULL.
audit_max defaults to 1000 non-sensitive decision events;
0 disables recording. Supplying
strategies = list(...) enables only listed
strategies and preserves list order. Use
shield$register_data(), $install(),
$describe(), $audit(),
$clear_audit(), $clear(), and
$close() for dynamic/session-owned workflows.
Plain-language glossary
| Term | Plain meaning |
|---|---|
| ingress | A tool call’s name/arguments entering local execution; scanned before the tool runs |
| egress | Content leaving a local tool and about to enter the LLM |
| row-cap | A limit on how many printed table lines may pass; 0
means no raw line |
| value-match | Exact matching against high-entropy values indexed from registered protected data |
| PII | Personally identifiable information, such as email, phone, identity number, or name |
| kind | What a registered asset is (dataset/spec/document/synthetic), not its R data type or access level |
| provenance | A verifiable source tag showing which registered asset produced a result |
| raw access | Content may enter an LLM edge without row/value restrictions; optional secret/PII scanning may still apply |
| regex | Regular expression: a text pattern such as
STUDY-[0-9]+
|
| PCRE | Perl-Compatible Regular Expression, the regex syntax used by R with
perl=TRUE
|
| span | Start/end character positions of a detected sensitive substring, enabling precise replacement |
| k-anonymity threshold | Do not expose a category label unless at least k rows
support it |
| fail closed | If a safety scanner fails, block output instead of allowing it |
| semantic reviewer | A separate small model that classifies what sanitized tool code is trying to do; it never sees raw data remotely |
| R6 | R’s mutable object system; one DataShield owns private
datasets/index/lifecycle |
Non-sensitive audit log
shield$audit() returns an in-memory data.frame of policy
decisions:
| Field | Meaning |
|---|---|
timestamp |
UTC event time |
edge |
ingress (before tool) or egress (before
LLM) |
tool_name, tool_call_id
|
Non-sensitive correlation identifiers |
strategy |
row_cap, value_match, regex,
ingress, or custom scanner name |
action, reason
|
redact/block/ask and a
rule/reason label |
match_count, score
|
Number of matches and normalized risk score |
It never stores raw tool arguments/results, matched
values, data rows, span text, or hashes. audit_max bounds
memory (oldest events are dropped); use
shield$clear_audit() to clear it. Each
DataShield R6 has its own log, so session/thread isolation
matches the policy instance.
recent <- shield$audit(limit=100)
shield$clear_audit()Data Asset Policy: what it is × what the LLM may see
Asset content type and LLM access are orthogonal:
kind |
Default prompt | Default egress | Typical use |
|---|---|---|---|
dataset |
schema |
scan |
patient/analysis data |
spec |
raw |
scan |
ADaM spec, SDTMIG, public dictionaries |
synthetic |
raw |
scan |
dummy/edge-case test data |
document |
scan |
scan |
ordinary reference documents |
| Access | Meaning |
|---|---|
none |
content unavailable to that LLM edge |
schema |
only strict DescribeData-style metadata |
scan |
content must pass configured Shield scanners |
raw |
bypass row/value restrictions; baseline secret/PII regex still applies unless explicitly disabled |
shield$register_asset(
adam_spec,
name = "adam_spec",
kind = "spec",
llm_access = list(prompt = "raw", egress = "scan"),
scan_secrets = TRUE,
reason = "Validated public specification",
expires = "session"
)
prompt_text <- shield$prompt_content("adam_spec")Raw egress never follows from kind alone. It requires
explicit policy and provenance:
shield$register_asset(
adam_spec, name = "adam_spec", kind = "spec",
llm_access = list(prompt = "raw", egress = "raw"),
reason = "Validated public specification")
tool_result <- shield$trusted_result(value, source = "adam_spec")An untagged or mixed tool result remains scanned. Raw asset policies
require a reason, expire with the owning DataShield session by default,
may set a POSIXct expiry, and emit bypass audit events. Synthetic raw
always keeps baseline PII/secret scanning; spec raw may explicitly set
scan_secrets = FALSE.
Column-level raw access
Assets are whole-object; register_data(column_access=)
is the column-grained counterpart for a protected data.frame that
contains a few public-dictionary columns (e.g. an SDTM
TESTCD codelist) alongside protected ones. It reuses the
same
none/schema/scan/raw
access levels as assets, split into
prompt/egress, and a raw edge likewise
requires a non-empty reason.
shield$register_data(
vs, name = "vs",
sensitivity = c(SUBJID = "identifier", TESTCD = "identifier"),
column_access = list(
TESTCD = list(prompt = "raw", egress = "raw",
reason = "SDTM public codelist", scan_secrets = TRUE)))-
prompt = "raw"letsDescribeDataenumerate that column’s real values (no k-anonymity suppression) so the model can write correct filters. -
egress = "raw"removes the column from the value-match index, so its values are not withheld from tool output. - An override missing its
reasonis a hard error (register_data()refuses the dataset), so a mislabeled raw grant cannot pass unnoticed.coverage()$raw_access_columnscounts active overrides.
egress tier caveat (kiro round-2 #8 / round-4 #7): on the EGRESS side only
none(a fixed-string deny of every value) andraw(removed from the index) have distinct behaviour today;schemaandscanboth fall through to the ordinary value-match scan — there is no structured schema/scan egress difference yet, and column-levelscan_secretscontrols the PROMPT (raw DescribeData) path, not egress. Full per-column egress tiers need result→column provenance (tracked, not yet implemented). The PROMPT tier implements all four levels.
Host pattern: a provenance-tagging spec tool
Raw asset egress needs a provenance tag. Rather than a framework
“trusted tool” type, a host composes the existing primitives —
register_asset() plus trusted_result() —
inside its own tool:
read_adam_spec_tool <- function(shield) {
ellmer::tool(
name = "ReadADaMSpec",
fun = function(path) {
text <- readLines(path, warn = FALSE)
shield$trusted_result(paste(text, collapse = "\n"), source = "adam_spec")
},
description = "Read a registered, LLM-safe ADaM specification.",
arguments = list(path = ellmer::type_string("Spec file path")))
}
# register the asset policy once; the tool auto-tags provenance on every read
shield$register_asset(adam_spec_path, name = "adam_spec", kind = "spec",
llm_access = list(prompt = "raw", egress = "raw"),
reason = "Validated public specification")
# separately, register the TOOL itself so the model can call it at all, then
# (re-)install so the egress wrapper picks up trusted_result()'s provenance
chat$register_tool(read_adam_spec_tool(shield))
shield$install(chat)The agent calls ReadADaMSpec like any tool; the raw
bypass is authorized by the registered asset policy and audited, and a
mixed/untagged result from any other tool is still scanned.
P0 — the foundation (available now)
The minimal, deterministic slice that already gives real protection:
# Easy entry: strategy specs create one private DataShield R6 for this client.
client <- codeagent_client(chat, data_shield = list(
shield_describe(k_anon = 5),
shield_egress(max_rows = 0)
))
# Harness-only client: attach tools, then install its R6 engine.
client <- codeagent_client(chat, register_tools = FALSE,
data_shield = list(shield_describe(), shield_egress(max_rows = 0)))
chat$register_tool(my_tool)
client$data_shield$install(client$chat)-
Edge 2 — shape-based egress row-cap. codeagent does
not inspect code or block
print. It looks only at the shape of a tool’s returned text: output with a data.frame / tibble print signature or a many-row rectangular table is truncated to a shape summary; scalars, messages, model summaries, plots and errors pass through untouched. Content-agnostic and tunable viamax_rows. -
Edge 1 — ambient stays schema-only. codeagent’s
ambient injection already emits only
name [data.frame N x M: col:type, ...](no values); Data Shield keeps it that way.
Runtime uploads in Shiny
The dataset does not need to be known when the app starts. Register it in the upload observer immediately after reading it; tools may already be attached and wrapped, because value matching reads the live index at invocation time.
# Inside each Shiny server session: one R6 may be shared by selected chats.
shield <- DataShield$new(
strategies = list(shield_describe(), shield_egress(max_rows = 0)))
data_env <- new.env(parent = emptyenv())
client_factory <- function() {
codeagent_client(make_chat(), data_shield = shield)
}
observeEvent(input$file, {
df <- read.csv(input$file$datapath)
data_env$uploaded <- df
shield$register_data(df, name = "uploaded") # no advance columns needed
})A complete runnable host-style Shiny example is installed at:
system.file("examples/data_shield_upload_app.R", package = "codeagent")From a development checkout:
It demonstrates five outcomes after upload: bulk rows withheld by
row_cap, a single indexed value withheld by
value_match, unregistered PII redacted by
shield_regex, a harmless shape summary passed through, and
strict DescribeData metadata with raw identifiers
suppressed.
For a smaller single-focus demo — a real
codeagent_client wired into a real
shinychat::chat_ui, with fileInput() upload on
one side and the live non-sensitive audit log on the other — see
inst/examples/data_shield_minimal_app.R.
Multi-user isolation: create
DataShield$new()inside the Shiny server function, share it only among the intended chat threads, and register data viashield$register_data(). Other browser sessions receive separate R6 instances and cannot see or influence its index.
Illustrative behaviour of the P0 row-cap (implemented):
| tool output | P0 action |
|---|---|
print(mtcars) (bulk rows) |
capped → shape summary |
a tibble print (# A tibble: 320 x 12) |
capped |
print(nrow(df)) → 320
|
passes |
| a status message | passes |
print(summary(fit)) |
passes (not row data) |
P1 DescribeData: strict safe metadata contract
DescribeData is the sanctioned way for the model to
understand protected data without receiving rows. Its output is
determined by three orthogonal dimensions:
| Dimension | Values | Purpose |
|---|---|---|
| Global policy | distributions = "off" / "on" / "dp" |
strict defaults to no distributions; on is explicit
opt-in; dp adds noise + budget |
| Column sensitivity | identifier / quasi / measure / open |
business role that clamps the maximum disclosure for that column |
| Data type | numeric / factor / character / Date / … | determines the safe representation: range, labels, or free-text marker |
Strict (distributions = "off") matrix:
| Metadata | identifier / quasi | measure / open |
|---|---|---|
| column name, type, missing presence | shown | shown |
| numeric/date min–max | hidden | shown |
| low-cardinality categorical labels | hidden | shown without counts; levels with support < k are
suppressed |
| real free-text examples | hidden | hidden |
| histogram, quantiles, mean/SD, category counts | hidden | hidden (on/dp opt-in only) |
A factor is not automatically safe: a factorised subject ID remains
an identifier. Character columns receive categorical labels
only when they are low-cardinality, low-uniqueness, non-PII, and every
exposed level satisfies the k-anonymity threshold. Free text never
receives real examples in strict mode.
P1.5 ordered egress scanners
Strategy-list order is the execution order.
shield_regex() catches sensitive content even when no
data.frame was registered:
client <- codeagent_client(chat, data_shield=list(
shield_egress(max_rows=0),
shield_regex(on_fail="redact"),
shield_regex(patterns=c(study_id="STUDY-[0-9]+"),
include_defaults=FALSE, on_fail="block")
))Built-ins cover email, phone-like strings, common API-token prefixes
and 18-character identity-number shapes. redact replaces
only matched spans; block discards the whole model-facing
result. Custom scanner functions may be appended with
shield$add_scanner(name, fn); invalid scanner
results/errors fail closed.
C2 universal ingress scanning
shield_ingress() is installed into codeagent’s existing
single central permission gate; it does not create a competing
callback/gate. It scans every tool’s arguments before the read-only fast
path. A block result raises tool_reject; an
ask result reuses the current CLI/Shiny approval callback,
including tool-call id correlation.
client <- codeagent_client(chat, data_shield=list(
shield_ingress(on_fail="ask"),
shield_egress(max_rows=0),
shield_regex()
))Defaults intentionally do not reject every Read or
print: nrow(study) and
print("done") pass, while head(study),
dput(study), base64/pickle/JSON serialization, upload-style
curl/requests calls, and shell display of data files are reviewed or
blocked.
C5 portable sandbox and btw boundary
shield_sandbox() deliberately preserves coding
capability: project and session-temp default to rwx,
protected data defaults to rw but may be rwx,
and process execution stays enabled. Its current portable backend blocks
explicit paths outside allowed roots, rejects symlink escape, and
applies network/process capability policy in the central gate.
btw is not assumed to provide OS isolation. Its file tools enforce
cwd with fs::path_has_parent() but a project-internal
symlink to an external file passed our probe; its RunR executes through
evaluate in the global environment and only restores
cwd/options/envvars. Data Shield therefore applies uniformly to native,
btw, MCP and host tools.
C4 semantic reviewer
shield_reviewer() supplements deterministic ingress
rules for indirect aliases, multi-step serialization and obfuscated
source-to-sink intent. Remote reviewers receive only sanitized code and
non-value metadata. A fresh independent Chat is created per review, with
no tools/history. The default factory uses the parent provider plus
CODEAGENT_FAST_MODEL; an explicit
client_factory may provide a local or specialized reviewer.
Missing/failed/invalid reviewers follow on_error; ask falls
back to block when no approval channel exists.
Roadmap
-
P0.5 —
value_match(available): deterministically catches targeted leaks the row-cap lets through (e.g. printing one patient’s name), by matching tool output against high-entropy values registered withshield$register_data(). -
P1 —
DescribeData+ protected-data registry (strict available): the model’s sanctioned hardened view (schema, sensitivity, missing presence, measure/open ranges and k-supported labels; no distributions/counts/examples in strict"off"mode).distributions="on"/"dp"(categorical counts, real or DP-noised with a per-dataset budget) are available; DP for numeric/continuous statistics remains a later phase (needs a host-supplied, data-independent clipping bound). -
P1.5 — ordered scanner pipeline +
shield_regex()(available): unregistered PII/secrets are redacted/blocked with precise spans; custom scanner failures fail closed. -
C2 —
shield_ingress()(available): all tool arguments pass the central permission gate; deterministic high-confidence rules block or force approval. -
C5 — portable
shield_sandbox()(available): project/temprwx, protected datarwby default, realpath/symlink containment and policy fallback; full OS process adapter remains roadmap. -
C4 —
shield_reviewer()(available): optional sanitized ingress-code semantic rail using a fresh small-model Chat; remote raw output remains forbidden. - P2 — full OS sandbox adapter and differential privacy for numeric/continuous statistics (opt-in).
Sub-agent boundary
Foreground sub-agents (Agent) inherit the exact same
DataShield R6 before any of their tools can return content
to the child model. This applies to both synchronous and concurrent
async Agent calls. While a shield is active, codeagent deliberately
skips btw/custom-agent delegation paths that cannot accept the policy
engine.
BackgroundAgent and /bg currently
fail closed under Data Shield: their mirai worker is a
separate R process and cannot safely share the session’s R6 state or
protected-value index. Use foreground Agent until a
per-owner worker reconstruction protocol is implemented.
Combination safety: what each combination actually protects against
Data Shield is composed from independent strategies, so it is
possible to enable a combination that looks protective but is
not. The table below is sourced directly from
tests/testthat/test-data-shield-combinations.R (a CI suite,
not prose): a future refactor that breaks any of these conclusions fails
that suite immediately, rather than silently drifting out of date.
| Combination | Bulk dump | Targeted single value | Alias bypass
(y <- study; print(y)) |
Verdict |
|---|---|---|---|---|
shield_egress() alone |
withheld | withheld | withheld (egress inspects output content, not the code path) | ✅ safe floor |
egress + ingress + regex
(recommended) |
withheld | withheld | withheld | ✅ recommended |
shield_ingress() alone |
leaks | leaks | leaks (confirmed) | ⚠️ not safe alone |
shield_describe() alone |
leaks | leaks | — | ⚠️ not safe alone (only governs the model’s own metadata query; does not filter other tools’ output) |
shield_regex() alone |
— | leaks for non-PII-shaped custom ids | — | ⚠️ only catches common PII shapes |
The one-line takeaway: shield_egress() is the
only non-optional boundary. Every other strategy is defense-in-depth and
cannot substitute for it.
Three ready-to-use combination templates
shield_preset_strict(),
shield_preset_balanced(), and
shield_preset_clinical() are callable functions returning
these exact combinations – no copy-pasting required:
# Strict: compliance / audit demos
strict <- shield_preset_strict()
# shield_describe(k_anon = 5),
# shield_egress(detectors = c("row_cap", "value_match"), max_rows = 0, on_fail = "block"),
# shield_regex(on_fail = "block"),
# shield_ingress(on_fail = "block")
# Balanced: everyday development, low friction
balanced <- shield_preset_balanced()
# shield_egress(max_rows = 0, on_fail = "redact"),
# shield_regex(on_fail = "redact")
# Clinical: adds the semantic reviewer + strict k-anonymity
clinical <- shield_preset_clinical()
# shield_describe(k_anon = 5),
# shield_egress(max_rows = 0),
# shield_regex(),
# shield_ingress(on_fail = "ask"),
# shield_reviewer(model = Sys.getenv("CODEAGENT_FAST_MODEL"), on_risk = "ask")
client <- codeagent_client(chat, data_shield = shield_preset_strict())Two intentionally UNSAFE demo combinations
These reproduce the two “not safe alone” rows of the matrix above,
exactly as wired in
inst/examples/data_shield_minimal_app.R’s “Shield strength”
selector — switch to one there, ask the chat to dump the uploaded data,
and watch it leak live.
# UNSAFE: ingress-only — no egress boundary at all.
unsafe_ingress_only <- list(shield_ingress(on_fail = "block"))
# UNSAFE: describe-only — DescribeData is registered, but nothing filters
# what OTHER tools return.
unsafe_describe_only <- list(shield_describe(k_anon = 3))-
unsafe_ingress_onlyonly scans tool arguments before execution (seeshield_ingress()in the parameter reference above). It has noshield_egress(), so nothing inspects what a tool actually returns. Its static regex rules also only see the literal code text:y <- study; print(y)does not match any “print/dump a known dataset name” pattern, so the alias sails through — this is the exact bypass the combination-safety table above marks “confirmed”. -
unsafe_describe_onlyonly registers theDescribeDatatool (seeshield_describe()above). That tool is the model’s own sanctioned query path; it does nothing to any other tool’s return value, so a tool that simply returns the raw data.frame is never touched. - Both omit
shield_egress()— see “Combination safety” above for why that is the one strategy every other combination depends on.
inst/examples/data_shield_minimal_app.R has a live
“Shield strength” selector covering the three templates above plus these
two intentionally UNSAFE combos, so you can switch between all five in a
running chat and watch the exact leaks in the table happen live.
Honest limits
Data Shield reduces disclosure risk; it does not
eliminate it. Deterministic detectors (row-cap,
value_match, regex) miss adversarially obfuscated egress
(e.g. base64-encoding data before printing); those are mitigated — not
solved — by the ingress blacklist and the no-network sandbox. The
strongest guarantees come from the structural layers (metadata-only
feeding + no-network execution), with scanning as defense-in-depth.
Specific residual risks to weigh before relying on it:
-
Layer composition matters — see the combination-safety table
above. Enabling only
shield_ingress()or onlyshield_describe()withoutshield_egress()is not a safe configuration. Do not omit egress. -
The semantic reviewer is itself an LLM.
shield_reviewer()can be evaded by a sufficiently obfuscated source-to-sink path and adds latency/cost per reviewed call; it is defense-in-depth over the deterministic rails, not a guarantee. -
value_matchscales linearly and is now bounded. Benchmarked on open-source CDISC-ADaM-format example data from the {pharmaverse} project (inst/bench/value_match_benchmark.R): ~130 MB of keys and ~10 s to index 1M high-entropy values, with zero false positives on ordinary clinical prose and pharmaverse-formatUSUBJID/SUBJIDcaught. Because memory grows linearly and unbounded,register_data(max_index_values=)caps the index (default 500 000, ~65 MB); on overflow it warns and the unindexed tail relies on the other egress layers. Themin_len/min_cardthresholds performed well on these ids and are unchanged. - Images/multimodal and full OS isolation remain roadmap (see the status banner): a rendered table/plot of raw rows bypasses text scanning, and the portable sandbox is a path/capability policy, not kernel isolation.
-
shinychattext-bearing attachments ARE now scanned at edge 1. codeagent’s main UI enableschat_ui(allow_attachments = TRUE); the input gate extracts and scans text-bearing attachments (e.g.ContentPDF), failing closed to block when the content cannot be verified (immutable Content cannot be redacted in place). Image attachments remain a blind spot unless an OCR scanner (data_shield_ocr_scanner()) is wired in — with a scanner configured, an OCR or scan failure fails closed to block.fileInput()→register_data()remains the controlled, indexed path for tabular uploads. -
Data Shield does not govern destructive operations
(
rm -rf, dropping a table, force-pushing) — that is a different axis (operation safety, not data confidentiality) covered by the permission gate and hooks, not by anyshield_*()strategy here.shield_ingress()’s pattern-matching pipeline can be repurposed for it, but its built-in defaults are exfiltration- focused, not destructiveness-focused. Seevignette("permissions")’s “Hooks” section for the actual mechanisms (arulesdeny glob, aPreToolUsehook, or a customshield_ingress()pattern) and why none of them are foolproof against rewording.