Skip to content

Commit f49c747

Browse files
committed
fix(skills): close four evasions found by adversarially probing the analyzers
Testing the gate against deliberate evasions rather than only friendly fixtures turned up four ways to reach a domain without producing a finding: - getattr(os, 'sys' + 'tem') resolved to nothing. Reflection over a local object is ordinary, but over an imported module it is how os.system gets spelled when someone does not want it found. A literal name now resolves to its real sink; a computed one is flagged, since it cannot be read from the source. - 'o = open' then o(path, 'w') escaped the resolver. Assignments that alias a sink or a sink-owning module are now followed, transitively. - Path(p).open('w') recorded nothing — the method form takes its mode as the first argument, not the second. - A prohibition could smuggle a directive: 'Do not forget: ignore all previous instructions' was suppressed, because the override pattern matched from 'forget' and finditer's non-overlapping scan let that suppressed match consume the real directive behind it. The guard now requires the prohibition to sit directly on the matched behaviour, and a suppressed candidate no longer hides a later one. Also fixes --tier patching the report after the audit instead of feeding it in, which left the verdict naming one tier while its tier-claim finding named another. A report that contradicts itself is worse than no report.
1 parent 3361b25 commit f49c747

7 files changed

Lines changed: 231 additions & 29 deletions

File tree

src/gaia/skills/audit/code.py

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,8 @@ class Sink:
484484
"chmod": Sink("filesystem", "write"),
485485
"symlink_to": Sink("filesystem", "write"),
486486
"hardlink_to": Sink("filesystem", "write"),
487+
# Path(...).open(mode) — level refined from the mode argument below.
488+
"open": Sink("filesystem", "read"),
487489
}
488490

489491
#: Names that are network *writes* when they appear as the final attribute.
@@ -507,8 +509,14 @@ class Sink:
507509
}
508510
)
509511

512+
#: Top-level module names that own at least one sink, so ``sp = subprocess``
513+
#: can be recognised as aliasing a module the audit cares about.
514+
_SINK_MODULE_ROOTS = frozenset(
515+
{key.split(".")[0] for key in SINKS if "." in key} | set(MODULE_PREFIX_SINKS)
516+
)
517+
510518
#: Builtin sinks — matched by bare name only when not shadowed locally.
511-
_BUILTIN_SINKS = frozenset({"eval", "exec", "compile", "__import__", "open"})
519+
_BUILTIN_SINKS = frozenset({"eval", "exec", "compile", "__import__", "open", "getattr"})
512520

513521
#: Paths whose mere mention means credential access.
514522
_CREDENTIAL_PATTERNS: tuple[tuple[str, str], ...] = (
@@ -616,14 +624,57 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None:
616624

617625
# -- the interesting bit --------------------------------------------
618626

627+
def visit_Assign(self, node: ast.Assign) -> None:
628+
"""Follow ``o = open`` / ``sp = subprocess`` so an alias is not an escape."""
629+
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
630+
resolved = self._resolve(node.value)
631+
if resolved is not None and (
632+
resolved in SINKS or resolved.split(".")[0] in _SINK_MODULE_ROOTS
633+
):
634+
self.aliases[node.targets[0].id] = resolved
635+
self.generic_visit(node)
636+
619637
def visit_Call(self, node: ast.Call) -> None:
620638
dotted = self._resolve(node.func)
621-
if dotted is not None:
639+
if dotted == "getattr":
640+
self._handle_getattr(node)
641+
elif dotted is not None:
622642
self._match_sink(node, dotted)
623643
else:
624644
self._match_method(node)
625645
self.generic_visit(node)
626646

647+
def _handle_getattr(self, node: ast.Call) -> None:
648+
"""Resolve or flag ``getattr(module, name)`` on an imported module.
649+
650+
Reflection over a local object is ordinary; reflection over an imported
651+
module is how ``os.system`` gets spelled when someone does not want it
652+
found. A literal name is resolved to its real sink; a computed one cannot
653+
be, so it is flagged rather than assumed harmless.
654+
"""
655+
if not node.args:
656+
return
657+
base = self._resolve(node.args[0])
658+
if base is None or base not in self.aliases.values():
659+
return # not an imported module — ordinary reflection
660+
661+
name_arg = node.args[1] if len(node.args) > 1 else None
662+
if isinstance(name_arg, ast.Constant) and isinstance(name_arg.value, str):
663+
dotted = f"{base}.{name_arg.value}"
664+
if dotted in SINKS or base in MODULE_PREFIX_SINKS:
665+
self._match_sink(node, dotted)
666+
return
667+
668+
self._add_finding(
669+
"code.exec.dynamic_attribute",
670+
"high",
671+
f"Looks up an attribute of the '{base}' module by a computed name, "
672+
"so what it actually calls cannot be read from the source.",
673+
node.lineno,
674+
f"Call the function directly ({base}.<name>(...)). A computed "
675+
"attribute name hides the call from this audit and from a reviewer.",
676+
)
677+
627678
def visit_Name(self, node: ast.Name) -> None:
628679
if isinstance(node.ctx, ast.Load) and node.id in {"builtins", "__builtins__"}:
629680
self._add_finding(
@@ -726,10 +777,14 @@ def _match_method(self, node: ast.Call) -> None:
726777
sink = METHOD_SINKS.get(node.func.attr)
727778
if sink is None or not sink.domain:
728779
return
780+
level = sink.level
781+
if node.func.attr == "open":
782+
# On the method form the mode is the first positional argument.
783+
level = _open_mode_level(node, mode_index=0)
729784
self.domain_uses.append(
730785
DomainUse(
731786
domain=sink.domain,
732-
level=sink.level,
787+
level=level,
733788
file=self.filename,
734789
line=node.lineno,
735790
detail=f".{node.func.attr}()",
@@ -869,11 +924,15 @@ def _has_shell_true(node: ast.Call) -> bool:
869924
return False
870925

871926

872-
def _open_mode_level(node: ast.Call) -> str:
873-
"""Return ``write`` when an ``open()`` call can modify the file."""
927+
def _open_mode_level(node: ast.Call, *, mode_index: int = 1) -> str:
928+
"""Return ``write`` when an ``open()`` call can modify the file.
929+
930+
``mode_index`` is 1 for the builtin (``open(path, mode)``) and 0 for the
931+
method form (``Path(path).open(mode)``).
932+
"""
874933
mode: Optional[ast.expr] = None
875-
if len(node.args) >= 2:
876-
mode = node.args[1]
934+
if len(node.args) > mode_index:
935+
mode = node.args[mode_index]
877936
for keyword in node.keywords:
878937
if keyword.arg == "mode":
879938
mode = keyword.value

src/gaia/skills/audit/engine.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,15 @@
4343
DESCRIPTION_SOURCE = f"{SKILL_FILENAME} (description)"
4444

4545

46-
def audit_skill(directory: Path | str) -> AuditReport:
46+
def audit_skill(directory: Path | str, *, tier: Optional[str] = None) -> AuditReport:
4747
"""Audit a skill directory and return its report.
4848
4949
Args:
5050
directory: The skill directory (the one containing ``SKILL.md``).
51+
tier: Audit as though the skill claimed this tier instead of its declared
52+
one, so an author can check a claim before making it. The whole
53+
verdict — including the tier-claim findings — is computed for it; a
54+
report must never name one tier while its findings name another.
5155
5256
Returns:
5357
An :class:`~gaia.skills.audit.findings.AuditReport` whose verdict is
@@ -62,19 +66,29 @@ def audit_skill(directory: Path | str) -> AuditReport:
6266
# check_directory_name=False: the audit runs on unpacked bundles and CI
6367
# checkouts where the folder name is not the author's choice.
6468
skill = parse_skill_file(directory, check_directory_name=False)
65-
return audit_skill_object(skill, directory=directory)
69+
return audit_skill_object(skill, directory=directory, tier=tier)
6670

6771

6872
def audit_skill_object(
69-
skill: Skill, *, directory: Optional[Path] = None
73+
skill: Skill,
74+
*,
75+
directory: Optional[Path] = None,
76+
tier: Optional[str] = None,
7077
) -> AuditReport:
7178
"""Audit an already-parsed :class:`Skill`.
7279
7380
Args:
7481
skill: The parsed skill.
7582
directory: Its directory. Defaults to ``skill.directory``; when neither
7683
is available only the instruction analyzers run (there are no files
77-
to scan).
84+
to scan), and ``manifest_digest`` is taken over the *re-serialized*
85+
manifest rather than the original bytes — so a report produced that
86+
way will not satisfy the publish path, which compares against the
87+
uploaded ``SKILL.md``.
88+
tier: Audit as though the skill claimed this tier instead of its declared
89+
one. The whole verdict, including the tier-claim findings, is computed
90+
for it — a report must never name one tier while its findings name
91+
another.
7892
"""
7993
directory = Path(directory) if directory is not None else skill.directory
8094

@@ -120,19 +134,19 @@ def audit_skill_object(
120134
digest = ""
121135
manifest = manifest_digest(skill.to_markdown())
122136

123-
tier = skill.security_tier
124-
verdict, reason = verdict_for_tier(findings, tier)
137+
claimed = tier or skill.security_tier
138+
verdict, reason = verdict_for_tier(findings, claimed)
125139
cleared = cleared_tiers(findings)
126140

127141
# Explain the tier outcome in the findings list. These are advisory by
128142
# construction ('info' gates nothing at any tier), so appending them after
129143
# the gate cannot change the verdict they describe.
130-
findings.extend(_tier_claim_findings(tier, cleared))
144+
findings.extend(_tier_claim_findings(claimed, cleared))
131145

132146
return AuditReport(
133147
skill=skill.name,
134148
version=skill.version,
135-
security_tier=tier,
149+
security_tier=claimed,
136150
verdict=verdict,
137151
reason=reason,
138152
findings=tuple(findings),

src/gaia/skills/audit/instructions.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,17 @@ def _rule(
221221
#:
222222
#: Applied only to text BEFORE the match, so rules whose own pattern opens with a
223223
#: negation — concealment's "do not tell the user" — are unaffected.
224+
#:
225+
#: The prohibition must sit *directly* on the matched behaviour: only whitespace
226+
#: and at most one intervening word, with no ``:`` / ``;`` / dash between. Anything
227+
#: looser turns the suppressor into the bypass — "Do not forget: ignore all
228+
#: previous instructions" prohibits *forgetting*, not ignoring. Verbs that negate
229+
#: the negation are excluded from the one-word allowance for the same reason.
224230
_PROHIBITION_RE = re.compile(
225-
r"\b(?:never|do not|don't|cannot|must not|should not|avoid|refuse to|"
226-
r"forbidden to|no need to)\b[^.]{0,20}$",
231+
r"\b(?:never|do not|don't|cannot|can't|must not|should not|shouldn't|"
232+
r"avoid|refuse to|forbidden to|no need to)\s+"
233+
r"(?!(?:forget|mind|worry|hesitate|fail)\b)"
234+
r"(?:\w+\s+)?$",
227235
re.IGNORECASE,
228236
)
229237

@@ -275,6 +283,25 @@ def flush() -> None:
275283
return blocks
276284

277285

286+
def _matches(pattern: "re.Pattern[str]", text: str):
287+
"""Yield ``(absolute_start, match)`` for every candidate, overlapping allowed.
288+
289+
``finditer`` returns non-overlapping matches, which lets a *suppressed* match
290+
hide a real one behind it: in "Do not forget: ignore all previous
291+
instructions" the override pattern matches from "forget" (a listed verb), the
292+
prohibition guard suppresses it, and the genuine directive starting at
293+
"ignore" was inside the span that got consumed. Advancing by one character
294+
after each candidate instead of past it keeps the later match reachable.
295+
"""
296+
position = 0
297+
while position < len(text):
298+
match = re.search(pattern, text[position:])
299+
if match is None:
300+
return
301+
yield position + match.start(), match
302+
position += match.start() + 1
303+
304+
278305
def _line_for_offset(offsets: list[tuple[int, int]], offset: int) -> int:
279306
"""Map a character offset inside a joined block back to a physical line."""
280307
line = offsets[0][1]
@@ -447,11 +474,11 @@ def analyze_instructions(
447474

448475
for text, offsets in _logical_blocks(body):
449476
for rule in INJECTION_RULES:
450-
for match in re.finditer(rule.pattern, text):
451-
if _PROHIBITION_RE.search(text[: match.start()]):
477+
for start, match in _matches(rule.pattern, text):
478+
if _PROHIBITION_RE.search(text[:start]):
452479
# "NEVER dump the environment" forbids the behaviour.
453480
continue
454-
number = _line_for_offset(offsets, match.start())
481+
number = _line_for_offset(offsets, start)
455482
key = (rule.rule_id, number)
456483
if key in seen:
457484
continue

src/gaia/skills/cli.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -446,23 +446,17 @@ def _handle_export(args: argparse.Namespace) -> int:
446446

447447
def _handle_audit(args: argparse.Namespace) -> int:
448448
"""Run the pre-publish security audit (issue #2468)."""
449-
from dataclasses import replace
450-
451449
from gaia.skills.audit import (
452450
SEVERITY_ORDER,
453451
audit_skill,
454452
render_json,
455453
render_sarif,
456454
render_text,
457-
verdict_for_tier,
458455
)
459456

460-
report = audit_skill(args.path)
461-
462-
tier = getattr(args, "tier", None)
463-
if tier and tier != report.security_tier:
464-
verdict, reason = verdict_for_tier(report.findings, tier)
465-
report = replace(report, security_tier=tier, verdict=verdict, reason=reason)
457+
# The tier override goes into the audit, not onto the report afterwards, so
458+
# the verdict and its tier-claim findings always agree with each other.
459+
report = audit_skill(args.path, tier=getattr(args, "tier", None))
466460

467461
show_snippets = getattr(args, "show_snippets", False)
468462

tests/unit/test_skills_audit_cli.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,54 @@ def test_tier_override_is_recorded_in_the_report(tmp_path, capsys):
236236
assert json.loads(capsys.readouterr().out)["security_tier"] == "community"
237237

238238

239+
def test_tier_override_recomputes_the_tier_claim_findings(tmp_path, capsys):
240+
"""The override must re-audit, not patch the report afterwards.
241+
242+
Otherwise the report says one tier while its tier-claim finding names
243+
another — a report that contradicts itself is worse than no report.
244+
"""
245+
directory = _write_skill(
246+
tmp_path, tools="import subprocess\ndef f():\n subprocess.run(['ls'])\n"
247+
)
248+
_run(["skill", "audit", str(directory), "--tier", "community", "--json"])
249+
payload = json.loads(capsys.readouterr().out)
250+
251+
assert payload["security_tier"] == "community"
252+
claim = [f for f in payload["findings"] if f["rule_id"] == "tier.not_cleared"]
253+
assert claim, "expected the unearned-claim finding for the overridden tier"
254+
# The claim clause must name the OVERRIDDEN tier, not the declared one.
255+
# (The message also names what was cleared — 'experimental' — which is the
256+
# point of it, so assert on the claim clause specifically.)
257+
assert "Claims the 'community' tier" in claim[0]["message"]
258+
assert "Claims the 'experimental' tier" not in claim[0]["message"]
259+
260+
261+
def test_tier_override_to_verified_reports_the_human_audit_hook(tmp_path, capsys):
262+
directory = _write_skill(tmp_path) # clean, declares experimental
263+
_run(["skill", "audit", str(directory), "--tier", "verified", "--json"])
264+
payload = json.loads(capsys.readouterr().out)
265+
266+
assert payload["verdict"] == "REVIEW"
267+
assert any(
268+
f["rule_id"] == "tier.human_audit_required" for f in payload["findings"]
269+
), [f["rule_id"] for f in payload["findings"]]
270+
271+
272+
def test_tier_override_down_to_the_declared_tier_leaves_no_claim_finding(
273+
tmp_path, capsys
274+
):
275+
directory = _write_skill(
276+
tmp_path,
277+
tier="community",
278+
tools="import subprocess\ndef f():\n subprocess.run(['ls'])\n",
279+
)
280+
_run(["skill", "audit", str(directory), "--tier", "experimental", "--json"])
281+
payload = json.loads(capsys.readouterr().out)
282+
283+
assert payload["verdict"] == "ALLOW"
284+
assert not [f for f in payload["findings"] if f["rule_id"].startswith("tier.")]
285+
286+
239287
def test_an_unknown_tier_is_rejected(tmp_path, capsys):
240288
directory = _write_skill(tmp_path)
241289
with pytest.raises(SystemExit):

tests/unit/test_skills_audit_code.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,49 @@ def test_a_local_function_named_like_a_sink_is_not_flagged():
214214
assert "code.shell.os_system" not in _rules(analysis)
215215

216216

217+
# ----------------------------------------------------------------------
218+
# Evasions — found by adversarially probing the resolver
219+
# ----------------------------------------------------------------------
220+
221+
222+
def test_computed_attribute_on_an_imported_module_is_flagged():
223+
"""``getattr(os, 'sys' + 'tem')`` is obfuscation, not ordinary reflection."""
224+
analysis = _analyze("import os\ngetattr(os, 'sys' + 'tem')('ls')\n")
225+
assert "code.exec.dynamic_attribute" in _rules(analysis)
226+
227+
228+
def test_computed_attribute_resolves_a_literal_name_to_its_sink():
229+
"""With a literal name the target IS knowable — resolve it, don't guess."""
230+
analysis = _analyze("import os\ngetattr(os, 'system')('ls')\n")
231+
assert ("shell", "execute") in _domains(analysis)
232+
233+
234+
def test_ordinary_getattr_on_a_local_object_is_not_flagged():
235+
analysis = _analyze("def f(obj, name):\n return getattr(obj, name, None)\n")
236+
assert "code.exec.dynamic_attribute" not in _rules(analysis)
237+
238+
239+
def test_a_sink_aliased_through_a_variable_is_resolved():
240+
"""``o = open`` then ``o(path, 'w')`` must still record the write."""
241+
analysis = _analyze("o = open\ndef f(p):\n o(p, 'w').write('x')\n")
242+
assert ("filesystem", "write") in _domains(analysis)
243+
244+
245+
def test_a_module_aliased_through_a_variable_is_resolved():
246+
analysis = _analyze("import subprocess\nsp = subprocess\nsp.run(['ls'])\n")
247+
assert "code.shell.subprocess" in _rules(analysis)
248+
249+
250+
def test_pathlib_open_for_writing_records_a_write():
251+
analysis = _analyze("from pathlib import Path\nPath('/tmp/x').open('w')\n")
252+
assert ("filesystem", "write") in _domains(analysis)
253+
254+
255+
def test_pathlib_open_for_reading_records_a_read():
256+
analysis = _analyze("from pathlib import Path\nPath('/tmp/x').open()\n")
257+
assert ("filesystem", "read") in _domains(analysis)
258+
259+
217260
# ----------------------------------------------------------------------
218261
# Network
219262
# ----------------------------------------------------------------------

0 commit comments

Comments
 (0)