Skill Tracer
Skills › Scientific Agent Skills › geopandas › sensitive_coordinates_checklist.py

sensitive_coordinates_checklist.py

230 lines · skills/geopandas/scripts/sensitive_coordinates_checklist.py at 5f2bc03

1#!/usr/bin/env python3
2"""Produce a deterministic privacy/generalization checklist without reading data."""
3
4from __future__ import annotations
5
6import argparse
7import sys
8
9from _common import CliError, emit_json, fail_json, finite_number, positive_int
11TOOL = "sensitive_coordinates_checklist"
12SENSITIVE_FLAGS = (
13 "precise_points",
14 "contains_addresses",
15 "trajectories",
16 "parcel_boundaries",
17 "rare_categories",
18 "linked_identifiers",
22def build_parser() -> argparse.ArgumentParser:
23 parser = argparse.ArgumentParser(
24 description=(
25 "Generate a conservative coordinate/privacy/generalization release "
26 "checklist. No file, environment variable, coordinate, or network is read."
27 ),
28 epilog=(
29 "This is a technical review aid, not a legal, ethical, or regulatory "
30 "compliance determination. Exact coordinates and addresses can reidentify."
31 ),
32 )
33 parser.add_argument(
34 "--audience",
35 choices=("private-analysis", "restricted-team", "public"),
36 default="private-analysis",
37 )
38 parser.add_argument("--public-output", action="store_true")
39 parser.add_argument("--precise-points", action="store_true")
40 parser.add_argument("--contains-addresses", action="store_true")
41 parser.add_argument("--trajectories", action="store_true")
42 parser.add_argument("--parcel-boundaries", action="store_true")
43 parser.add_argument("--rare-categories", action="store_true")
44 parser.add_argument("--linked-identifiers", action="store_true")
45 parser.add_argument(
46 "--generalization",
47 action="append",
48 choices=(
49 "aggregate",
50 "coarsen-coordinates",
51 "simplify",
52 "suppress-small-groups",
53 "remove-sensitive-fields",
54 "mask-extent",
55 ),
56 default=[],
57 help="Applied/proposed control; repeat for multiple controls",
58 )
59 parser.add_argument(
60 "--minimum-group-size",
61 type=positive_int,
62 help="Smallest released aggregate group; not a compliance threshold",
63 )
64 parser.add_argument(
65 "--tolerance",
66 help="Optional positive generalization tolerance; value is not echoed",
67 )
68 crs = parser.add_mutually_exclusive_group()
69 crs.add_argument(
70 "--projected-linear-crs",
71 action="store_true",
72 help="Attest tolerance uses a reviewed projected linear CRS",
73 )
74 crs.add_argument(
75 "--geographic-crs",
76 action="store_true",
77 help="Mark coordinates/tolerance as angular longitude-latitude",
78 )
79 parser.add_argument("--direct-identifiers-removed", action="store_true")
80 parser.add_argument("--attribute-review-complete", action="store_true")
81 parser.add_argument("--reidentification-review-complete", action="store_true")
82 parser.add_argument("--visual-review-complete", action="store_true")
83 parser.add_argument("--provenance-recorded", action="store_true")
84 parser.add_argument("--tile-and-cdn-access-disabled", action="store_true")
85 return parser
88def checklist(args: argparse.Namespace) -> dict:
89 public = bool(args.public_output or args.audience == "public")
90 flags = {name: bool(getattr(args, name)) for name in SENSITIVE_FLAGS}
91 sensitive = any(flags.values())
92 controls = sorted(set(args.generalization))
93 blockers: list[str] = []
94 warnings: list[str] = []
96 tolerance_provided = args.tolerance is not None
97 if tolerance_provided:
98 tolerance = finite_number(args.tolerance, name="tolerance")
99 if tolerance <= 0:
100 raise CliError("tolerance must be greater than zero")
101 if not args.projected_linear_crs:
102 blockers.append(
103 "a numeric generalization tolerance requires a reviewed projected linear CRS"
104 )
105 if args.geographic_crs and any(
106 item in controls for item in ("coarsen-coordinates", "simplify")
107 ):
108 blockers.append(
109 "angular-coordinate generalization needs an explicit distortion-aware design"
110 )
112 if public and sensitive:
113 if not controls:
114 blockers.append("public sensitive geodata requires documented generalization")
115 if not args.direct_identifiers_removed:
116 blockers.append("remove direct identifiers before public release")
117 if not args.attribute_review_complete:
118 blockers.append("complete an attribute disclosure review")
119 if not args.reidentification_review_complete:
120 blockers.append("complete a linkage/reidentification review")
121 if not args.visual_review_complete:
122 blockers.append("inspect the rendered and serialized outputs")
123 if not args.provenance_recorded:
124 blockers.append("record source and generalization provenance")
125 if public and not args.tile_and_cdn_access_disabled:
126 blockers.append(
127 "disable or explicitly approve tile/CDN access for the public artifact"
128 )
129 if args.contains_addresses and "remove-sensitive-fields" not in controls:
130 blockers.append("exact addresses require removal from released attributes")
131 if args.trajectories and "aggregate" not in controls:
132 blockers.append("trajectories require aggregation or a separately reviewed model")
133 spatial_controls = {
134 "aggregate",
135 "coarsen-coordinates",
136 "mask-extent",
137 "simplify",
138 }
139 if (
140 (args.precise_points or args.parcel_boundaries)
141 and public
142 and not set(controls) & spatial_controls
143 ):
144 blockers.append("exact geometry needs spatial generalization before release")
145 if (
146 (args.rare_categories or args.linked_identifiers)
147 and public
148 and "suppress-small-groups" not in controls
149 ):
150 blockers.append("small/rare linked groups require suppression review")
152 if args.minimum_group_size is None and public and sensitive:
153 blockers.append("define and review a minimum released aggregate group size")
154 elif args.minimum_group_size is not None:
155 if args.minimum_group_size < 5:
156 warnings.append(
157 "The declared group size is very small; no universal threshold guarantees privacy."
158 )
159 if "suppress-small-groups" not in controls:
160 warnings.append(
161 "A group-size value was supplied without the suppress-small-groups control."
162 )
164 if not sensitive:
165 warnings.append(
166 "No sensitivity flags were selected; verify that classification is complete."
167 )
168 if not public:
169 warnings.append(
170 "Restricted handling still needs access control, retention, and audit policy."
171 )
172 warnings.extend(
173 [
174 "Jittering alone is not anonymization and can preserve reidentifiable patterns.",
175 "Rasterization, simplification, or coordinate rounding alone does not remove sensitive attributes.",
176 "HTML/SVG/GeoJSON can embed exact coordinates and properties beyond what is visibly rendered.",
177 ]
178 )
180 return {
181 "ok": not blockers,
182 "tool": TOOL,
183 "not_a_compliance_determination": True,
184 "audience": "public" if public else args.audience,
185 "sensitivity": {
186 "any_flagged": sensitive,
187 "categories": flags,
188 },
189 "controls": controls,
190 "minimum_group_size_declared": args.minimum_group_size is not None,
191 "tolerance": {
192 "provided": tolerance_provided,
193 "value_emitted": False,
194 "projected_linear_crs_attested": bool(args.projected_linear_crs),
195 "geographic_crs_flagged": bool(args.geographic_crs),
196 },
197 "review_attestations": {
198 "direct_identifiers_removed": bool(args.direct_identifiers_removed),
199 "attribute_review_complete": bool(args.attribute_review_complete),
200 "reidentification_review_complete": bool(
201 args.reidentification_review_complete
202 ),
203 "visual_review_complete": bool(args.visual_review_complete),
204 "provenance_recorded": bool(args.provenance_recorded),
205 "tile_and_cdn_access_disabled": bool(
206 args.tile_and_cdn_access_disabled
207 ),
208 },
209 "blockers": blockers,
210 "warnings": warnings,
211 "files_opened": False,
212 "environment_read": False,
213 "network_accessed": False,
214 "coordinates_emitted": False,
215 "identifiers_emitted": False,
216 }
219def main(argv: list[str] | None = None) -> int:
220 args = build_parser().parse_args(argv)
221 try:
222 report = checklist(args)
223 emit_json(report)
224 return 0 if report["ok"] else 2
225 except Exception as exc: # noqa: BLE001 - errors are redacted at CLI boundary
226 return fail_json(TOOL, exc)
229if __name__ == "__main__":
230 sys.exit(main())