Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
**Vulnerability:** Unvalidated inputs passed to `if()` statements can cause process crashes (`condition has length > 1`) or unexpected coercion vulnerabilities.
**Learning:** In R, optional boolean parameters that default to `NULL` should be validated using explicit runtime type validation (e.g., `if (!is.null(flag) && (!is.logical(flag) || length(flag) != 1 || is.na(flag)))`).
**Prevention:** Always implement explicit runtime type validation for optional boolean parameters.

## 2026-08-13 - Bound interactive binary choices before integer coercion
**Vulnerability:** A menu prompt accepted any digit string with `^[0-9]+$`; an out-of-range integer could coerce to `NA_integer_` and abort later condition evaluation.
**Learning:** Validate menu input against the documented value set before coercion.
**Prevention:** Use an exact bounded expression such as `^[12]$` for binary choices and retain a bounded retry limit.
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Description: Automates fixed item parameter linking for test linking under
the item response theory paradigm using mirt package estimates.
License: GPL-3 | file LICENSE
Imports: mirt, methods
Suggests: testthat (>= 3.0.0)
Suggests: testthat (>= 3.2.0)
Encoding: UTF-8
Config/testthat/edition: 3
Config/roxygen2/version: 8.0.0
6 changes: 3 additions & 3 deletions R/aFIPC.R
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ autoFIPC <-
}
for (attempt in seq_len(3)) {
n <- readline(prompt = "Is it correct? (1: Yes 2: No) : ")
if (grepl("^[0-9]+$", n)) {
if (grepl("^[12]$", n)) {
return(as.integer(n))
}
}
Expand Down Expand Up @@ -171,7 +171,7 @@ autoFIPC <-
readline(
prompt = "Do you want to use default BILOG-MG priors for oldform Data? (1: Yes 2: No) : "
)
if (grepl("^[0-9]+$", n)) {
if (grepl("^[12]$", n)) {
return(as.integer(n))
}
}
Expand Down Expand Up @@ -390,7 +390,7 @@ autoFIPC <-
readline(
prompt = "Do you want to use default BILOG-MG priors for newform Data? (1: Yes 2: No) : "
)
if (grepl("^[0-9]+$", n)) {
if (grepl("^[12]$", n)) {
return(as.integer(n))
}
}
Expand Down
8 changes: 8 additions & 0 deletions R/mock-bindings.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Internal namespace bindings for deterministic tests of interactive code.
#
# Function lookup skips these NULL values during normal execution and continues
# to the corresponding base functions. testthat can temporarily replace the
# pre-existing namespace bindings during R CMD check, where the namespace is
# locked and new bindings cannot be created.
interactive <- NULL
readline <- NULL
106 changes: 106 additions & 0 deletions tests/testthat/test-sentinel-input-validation.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
binary_prompt_data <- function(row_count = 40L) {
set.seed(20260813)
data.frame(
item_1 = stats::rbinom(row_count, 1L, 0.45),
item_2 = stats::rbinom(row_count, 1L, 0.55),
item_3 = stats::rbinom(row_count, 1L, 0.50),
item_4 = stats::rbinom(row_count, 1L, 0.60)
)
}

test_that("common-item confirmation rejects oversized integer input after three attempts", {
testthat::local_mocked_bindings(
interactive = function() TRUE,
readline = testthat::mock_output_sequence(
"999999999999999999999",
"999999999999999999999",
"999999999999999999999"
),
.package = "aFIPC"
)

response_data <- binary_prompt_data()
expect_error(
suppressMessages(aFIPC::autoFIPC(
newformXData = response_data,
oldformYData = response_data,
newformCommonItemNames = c("item_1", "item_2"),
oldformCommonItemNames = c("item_1", "item_2"),
itemtype = "2PL"
)),
"Too many invalid common item confirmation attempts",
fixed = TRUE
)
})

test_that("old-form BILOG prompt rejects values outside the documented binary choices", {
testthat::local_mocked_bindings(
interactive = function() TRUE,
readline = testthat::mock_output_sequence(
"1",
"0",
"3",
"999999999999999999999"
),
.package = "aFIPC"
)

response_data <- binary_prompt_data()
expect_error(
suppressMessages(aFIPC::autoFIPC(
newformXData = response_data,
oldformYData = response_data,
newformCommonItemNames = c("item_1", "item_2"),
oldformCommonItemNames = c("item_1", "item_2"),
itemtype = "3PL"
)),
"Too many invalid oldform BILOG prior attempts",
fixed = TRUE
)
})

test_that("new-form BILOG prompt applies the same bounded validation contract", {
skip_if_not_installed("mirt")

set.seed(20260814)
item_names <- paste0("item_", 1:4)
old_data <- as.data.frame(mirt::simdata(
a = matrix(c(0.9, 1.1, 0.8, 1.2), ncol = 1L),
d = c(-0.5, 0.0, 0.5, 0.8),
itemtype = rep("2PL", 4L),
N = 250L
))
names(old_data) <- item_names
old_model <- mirt::mirt(
old_data,
1L,
itemtype = "2PL",
SE = FALSE,
verbose = FALSE,
technical = list(NCYCLES = 300L)
)

testthat::local_mocked_bindings(
interactive = function() TRUE,
readline = testthat::mock_output_sequence(
"1",
"0",
"3",
"999999999999999999999"
),
.package = "aFIPC"
)

new_data <- binary_prompt_data(row_count = 250L)
expect_error(
suppressMessages(aFIPC::autoFIPC(
newformXData = new_data,
oldformYData = old_model,
newformCommonItemNames = c("item_1", "item_2"),
oldformCommonItemNames = c("item_1", "item_2"),
itemtype = "3PL"
)),
"Too many invalid newform BILOG prior attempts",
fixed = TRUE
)
})
Loading