Skip to content
Merged
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
105 changes: 105 additions & 0 deletions scripts/security/skillspector-exception-diagnostic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,111 @@ print(json.dumps([m.project_exception(exc) for exc in cases]))
).toThrow();
});

it("projects qualified serializer state and fixed tokens without private text or type names", async () => {
const code = `
import importlib.util,json,sys,types
spec=importlib.util.spec_from_file_location("diagnostic",sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
sdk=types.ModuleType("openai.types.responses.response")
class Response:pass
sdk.Response=Response;sys.modules[sdk.__name__]=sdk
parsed=types.ModuleType("openai.types.responses.parsed_response")
Mock=type("MockValSer",(),{"__module__":"pydantic._internal._mock_val_ser"})
class Parsed(Response):
__pydantic_complete__=False
__pydantic_serializer__=Mock()
parsed.ParsedResponse=Parsed;sys.modules[parsed.__name__]=parsed
trusted=types.ModuleType("pydantic.main");trusted.__file__="/qualified-pydantic-main.py";sys.modules[trusted.__name__]=trusted
exec(compile("def model_dump(self):raise TypeError(\\"PRIVATE 'MockValSer' object is not an instance of 'SchemaSerializer' PRIVATE\\")",trusted.__file__,"exec"),trusted.__dict__)
try:trusted.model_dump(Parsed())
except TypeError as exc:projected=m.project_exception(exc)
fake={"__name__":"pydantic.main"}
exec(compile("def model_dump(self):raise TypeError(\\"PRIVATE MockValSer SchemaSerializer\\")",trusted.__file__,"exec"),fake)
try:fake["model_dump"](Parsed())
except TypeError as exc:forged=m.project_exception(exc)
exec(compile("def model_dump(self,error):raise error",trusted.__file__,"exec"),trusted.__dict__)
def project(error):
try:trusted.model_dump(Parsed(),error)
except TypeError as exc:return m.project_exception(exc)
class Sneaky:
def __str__(self):raise AssertionError("PRIVATE stringification")
other=project(TypeError(Sneaky()))
oversize=project(TypeError("PRIVATE MockValSer "+"x"*2048))
Parsed.__pydantic_complete__=property(lambda self:(_ for _ in ()).throw(AssertionError("PRIVATE getter")))
Parsed.__pydantic_serializer__=property(lambda self:(_ for _ in ()).throw(AssertionError("PRIVATE getter")))
descriptor=project(TypeError("PRIVATE"))
print(json.dumps({"trusted":projected,"forged":forged,"other":other,"oversize":oversize,"descriptor":descriptor}))
`;
const result = await promisify(execFile)(
"python3",
["-B", "-c", code, new URL("./skillspector_exception_diagnostic.py", import.meta.url).pathname],
{ env: { PATH: process.env.PATH } },
);
expect(result.stdout).not.toContain("PRIVATE");
expect(result.stdout).not.toContain("/qualified-");
const data = JSON.parse(result.stdout);
expect(data.trusted[0].serializerFailure).toEqual({
builtinTypeError: true,
argumentKinds: ["str"],
argumentsTruncated: false,
qualifiedDump: true,
tokensInspected: true,
tokens: ["mock_val_ser", "schema_serializer", "not_instance"],
model: "parsed_response",
modelComplete: false,
serializer: "mock",
});
expect(data.forged[0].serializerFailure).toMatchObject({ qualifiedDump: false, tokens: [] });
expect(data.other[0].serializerFailure).toMatchObject({
argumentKinds: ["other"],
tokensInspected: false,
tokens: [],
});
expect(data.oversize[0].serializerFailure).toMatchObject({
argumentKinds: ["str"],
tokensInspected: false,
tokens: [],
});
expect(data.descriptor[0].serializerFailure).toMatchObject({
modelComplete: null,
serializer: "other",
});
const event = { rootIndex: 19, kind: "failure", chain: data.trusted };
expect(projectSkillSpectorExceptionEvents(JSON.stringify(event))[0]).toMatchObject({
chain: [{ serializerFailure: data.trusted[0].serializerFailure }],
});
expect(() =>
projectSkillSpectorExceptionEvents(
JSON.stringify({
...event,
chain: [
{
...data.trusted[0],
serializerFailure: {
...data.trusted[0].serializerFailure,
tokens: ["PRIVATE"],
},
},
],
}),
),
).toThrow();
for (const serializerFailure of [
{ ...data.trusted[0].serializerFailure, tokensInspected: false },
{ ...data.trusted[0].serializerFailure, argumentKinds: ["str", "str", "str", "str"] },
{ ...data.trusted[0].serializerFailure, model: "PRIVATE" },
{ ...data.trusted[0].serializerFailure, serializer: "PRIVATE" },
]) {
expect(() =>
projectSkillSpectorExceptionEvents(
JSON.stringify({
...event,
chain: [{ ...data.trusted[0], serializerFailure }],
}),
),
).toThrow();
}
});

it("installs only over the qualified venv entrypoint and reads private regular files only", async () => {
const root = await mkdtemp(join(tmpdir(), "exception-observer-"));
roots.push(root);
Expand Down
76 changes: 76 additions & 0 deletions scripts/security/skillspector-exception-diagnostic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,81 @@ const record = (v: unknown): Record<string, unknown> | undefined =>
: undefined;
const integer = (v: unknown, maximum: number): v is number =>
typeof v === "number" && Number.isSafeInteger(v) && v >= 0 && v <= maximum;
const errorTokens = [
"mock_val_ser",
"schema_serializer",
"not_instance",
"cannot_convert",
"none_type",
"unexpected_keyword",
"required_argument",
"not_callable",
"not_iterable",
"unhashable",
"unknown_type",
"not_json",
"serialization_info",
"polymorphic",
"serialize_as_any",
"include",
"exclude",
"dict_type",
"list_type",
"str_type",
"int_type",
"tuple_type",
"bytes_type",
];

function projectSerializerFailure(raw: unknown, exceptionClass: string) {
if (raw === undefined || raw === null) return null;
const value = record(raw);
if (
!value ||
exceptionClass !== "TypeError" ||
typeof value.builtinTypeError !== "boolean" ||
!Array.isArray(value.argumentKinds) ||
value.argumentKinds.length > 3 ||
!value.argumentKinds.every((kind) =>
["str", "int", "float", "bool", "none", "bytes", "other"].includes(kind),
) ||
typeof value.argumentsTruncated !== "boolean" ||
typeof value.qualifiedDump !== "boolean" ||
typeof value.tokensInspected !== "boolean" ||
!Array.isArray(value.tokens) ||
value.tokens.length > errorTokens.length ||
!value.tokens.every((token) => errorTokens.includes(token)) ||
new Set(value.tokens).size !== value.tokens.length ||
!["unavailable", "parsed_response", "response", "other"].includes(value.model as string) ||
!(value.modelComplete === null || typeof value.modelComplete === "boolean") ||
!["unavailable", "schema", "mock", "missing", "other"].includes(value.serializer as string) ||
(!value.qualifiedDump &&
(value.tokens.length > 0 ||
value.tokensInspected ||
value.model !== "unavailable" ||
value.modelComplete !== null ||
value.serializer !== "unavailable")) ||
(value.tokens.length > 0 && !value.tokensInspected) ||
(value.tokensInspected &&
(!value.builtinTypeError ||
value.argumentsTruncated ||
JSON.stringify(value.argumentKinds) !== '["str"]')) ||
(!value.builtinTypeError && (value.argumentKinds.length > 0 || value.argumentsTruncated)) ||
(value.model === "other" && (value.modelComplete !== null || value.serializer !== "other"))
)
throw new Error("Invalid serializer failure projection");
return {
builtinTypeError: value.builtinTypeError,
argumentKinds: value.argumentKinds as string[],
argumentsTruncated: value.argumentsTruncated,
qualifiedDump: value.qualifiedDump,
tokensInspected: value.tokensInspected,
tokens: errorTokens.filter((token) => (value.tokens as string[]).includes(token)),
model: value.model as string,
modelComplete: value.modelComplete as boolean | null,
serializer: value.serializer as string,
};
}

export async function installSkillSpectorExceptionObserver(workspace: string, runnerTemp: string) {
const runnerRoot = await realpath(runnerTemp);
Expand Down Expand Up @@ -131,6 +206,7 @@ export function projectSkillSpectorExceptionEvents(text: string) {
return {
class: value.class,
mockSchemaSerializer: value.mockSchemaSerializer === true,
serializerFailure: projectSerializerFailure(value.serializerFailure, value.class),
statusCode: value.statusCode as number | null,
unknownFrames: value.unknownFrames,
framesTruncated: value.framesTruncated,
Expand Down
70 changes: 70 additions & 0 deletions scripts/security/skillspector_exception_diagnostic.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,72 @@
"APIConnectionError", "APITimeoutError", "BadRequestError", "InternalServerError",
"AuthenticationError", "PermissionDeniedError", "APIResponseValidationError",
}
ERROR_TOKENS = {
"mock_val_ser": "MockValSer", "schema_serializer": "SchemaSerializer",
"not_instance": "not an instance", "cannot_convert": "cannot be converted",
"none_type": "NoneType", "unexpected_keyword": "unexpected keyword",
"required_argument": "required positional argument", "not_callable": "not callable",
"not_iterable": "not iterable", "unhashable": "unhashable",
"unknown_type": "unknown type", "not_json": "not JSON serializable",
"serialization_info": "SerializationInfo", "polymorphic": "polymorphic_serialization",
"serialize_as_any": "serialize_as_any", "include": "include", "exclude": "exclude",
"dict_type": "'dict'", "list_type": "'list'", "str_type": "'str'",
"int_type": "'int'", "tuple_type": "'tuple'", "bytes_type": "'bytes'",
}


def serializer_failure(exc, frame):
if type(exc).__name__ != "TypeError":
return None
builtin = type(exc) is TypeError
args = exc.args if builtin else ()
kinds = ((str, "str"), (int, "int"), (float, "float"), (bool, "bool"),
(type(None), "none"), (bytes, "bytes"))
value = {
"builtinTypeError": builtin,
"argumentKinds": [next((label for cls, label in kinds if type(arg) is cls), "other")
for arg in args[:3]],
"argumentsTruncated": len(args) > 3,
"qualifiedDump": frame is not None,
"tokensInspected": False,
"tokens": [], "model": "unavailable", "modelComplete": None,
"serializer": "unavailable",
}
if frame is None:
return value
if len(args) == 1 and type(args[0]) is str and len(args[0]) <= 2048:
value["tokensInspected"] = True
value["tokens"] = [label for label, token in ERROR_TOKENS.items() if token in args[0]]
# Only inspect class dictionaries; never call model properties or serializers.
cls = type(frame.f_locals.get("self"))
mro = cls.__mro__[:16]
response_module = sys.modules.get("openai.types.responses.response")
parsed_module = sys.modules.get("openai.types.responses.parsed_response")
response = vars(response_module).get("Response") if response_module else None
parsed = vars(parsed_module).get("ParsedResponse") if parsed_module else None
family = "parsed_response" if parsed in mro else "response" if response in mro else "other"
value["model"] = family
value["serializer"] = "other"
if family == "other":
return value
def class_value(key):
missing = object()
for base in mro:
result = base.__dict__.get(key, missing)
if result is not missing:
return result
return None

complete = class_value("__pydantic_complete__")
value["modelComplete"] = complete if type(complete) is bool else None
serializer = class_value("__pydantic_serializer__")
serializer_type = type(serializer)
value["serializer"] = {
("pydantic_core._pydantic_core", "SchemaSerializer"): "schema",
("pydantic._internal._mock_val_ser", "MockValSer"): "mock",
("builtins", "NoneType"): "missing",
}.get((serializer_type.__module__, serializer_type.__name__), "other")
return value


def project_exception(exc):
Expand All @@ -49,6 +115,7 @@ def project_exception(exc):
unknown = 0
trace = exc.__traceback__
traversed = 0
dump_frame = None
while trace is not None and traversed < 64:
traversed += 1
frame = trace.tb_frame
Expand All @@ -60,6 +127,8 @@ def project_exception(exc):
and os.path.realpath(frame.f_code.co_filename) == os.path.realpath(source)
and 0 < trace.tb_lineno <= 1_000_000):
frames.append({"module": MODULES[name], "line": trace.tb_lineno})
if name == "pydantic.main" and frame.f_code.co_name == "model_dump" and dump_frame is None:
dump_frame = frame
else:
unknown += 1
trace = trace.tb_next
Expand All @@ -71,6 +140,7 @@ def project_exception(exc):
and type(exc.args[0]) is str
and exc.args[0] == "'MockValSer' object cannot be converted to 'SchemaSerializer'"
),
"serializerFailure": serializer_failure(exc, dump_frame),
"statusCode": code if type(code) is int and 100 <= code <= 599 else None,
"frames": frames[-8:],
"unknownFrames": unknown,
Expand Down
12 changes: 10 additions & 2 deletions specs/security-moderation.md
Original file line number Diff line number Diff line change
Expand Up @@ -660,8 +660,16 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
status codes, root indices, counts and lifecycle flags. One boolean may identify
the exact known Pydantic MockValSer/SchemaSerializer TypeError; matching must
inspect only its single string argument without stringifying arbitrary values
or exporting any message text. Frame provenance must
match the loaded module's globals and source filename. Export limits are three
or exporting any message text. Only a qualified Pydantic `model_dump` frame may
provide fixed enums for response family and serializer kind, plus whether the
model is built. These come from class dictionaries without calling properties
or serializers.
TypeError argument metadata is capped at three primitive-type labels. Up to 23
fixed public error/type tokens may be recognized in a sole built-in string
argument of at most 2,048 characters; unknown text is always discarded. These
tokens classify known vocabulary rather than exporting message fragments.
Frame provenance must match the loaded module's globals and source filename.
Export limits are three
chained exceptions, eight frames per exception and eight failure records per
root; traversal is capped at 64 frames, line numbers at 1,000,000 and the private
event file at 2 MiB/1,024 records. Reaching a limit that loses observer records
Expand Down
Loading