claude-dev-env 1.49.0 → 1.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/audit-rubrics/category_rubrics/category-a-api-contracts.md +86 -0
- package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +36 -0
- package/audit-rubrics/category_rubrics/category-c-resource-cleanup.md +35 -0
- package/audit-rubrics/category_rubrics/category-d-scoping-and-ordering.md +35 -0
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +38 -0
- package/audit-rubrics/category_rubrics/category-f-silent-failures.md +38 -0
- package/audit-rubrics/category_rubrics/category-g-bounds-and-overflow.md +38 -0
- package/audit-rubrics/category_rubrics/category-h-security-boundaries.md +40 -0
- package/audit-rubrics/category_rubrics/category-i-concurrency.md +38 -0
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +46 -0
- package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +59 -0
- package/audit-rubrics/category_rubrics/category-l-behavior-equivalence.md +45 -0
- package/audit-rubrics/category_rubrics/category-m-producer-consumer-cardinality.md +44 -0
- package/audit-rubrics/category_rubrics/category-n-test-name-scenario-verifier.md +45 -0
- package/audit-rubrics/prompts/category-a-api-contracts.md +399 -0
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +401 -0
- package/audit-rubrics/prompts/category-c-resource-cleanup.md +420 -0
- package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +414 -0
- package/audit-rubrics/prompts/category-e-dead-code.md +420 -0
- package/audit-rubrics/prompts/category-f-silent-failures.md +420 -0
- package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +383 -0
- package/audit-rubrics/prompts/category-h-security-boundaries.md +423 -0
- package/audit-rubrics/prompts/category-i-concurrency.md +429 -0
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +463 -0
- package/audit-rubrics/prompts/category-k-codebase-conflicts.md +328 -0
- package/audit-rubrics/prompts/category-l-behavior-equivalence.md +128 -0
- package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +129 -0
- package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +132 -0
- package/audit-rubrics/source-material-section-types.md +51 -0
- package/docs/CODE_RULES.md +6 -1
- package/hooks/blocking/code_rules_enforcer.py +323 -11
- package/hooks/blocking/md_to_html_blocker.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer.py +65 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_signature.py +256 -0
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +256 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +137 -1
- package/hooks/blocking/test_md_to_html_blocker.py +38 -0
- package/hooks/hooks_constants/blocking_check_limits.py +2 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +15 -1
- package/hooks/hooks_constants/md_to_html_blocker_constants.py +1 -1
- package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +11 -4
- package/package.json +2 -1
- package/skills/bugteam/reference/teardown-publish-permissions.md +7 -2
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
Audit [REPO/ARTIFACT] [TARGET_ID] for **Category H only** (security boundaries). Skip A–G, I–K. Sub-bucket forced-exhaustion mode: Category H is decomposed into 10 sub-buckets below. Each sub-bucket REQUIRES at least one Shape A finding OR exactly one Shape B proof-of-absence with **at least 3 adversarial probes** specific to that sub-bucket. A sub-bucket returning neither is a protocol gap.
|
|
2
|
+
|
|
3
|
+
## ARTIFACT METADATA — trust model
|
|
4
|
+
|
|
5
|
+
[Describe the artifact under audit: what it is, how it is invoked, who invokes it, and what privilege it runs with. Then explicitly name the **trust model**:]
|
|
6
|
+
|
|
7
|
+
- **Who is the attacker?** [remote unauthenticated caller / authenticated tenant / co-located process / operator-only / no attacker surface — pick one and justify]
|
|
8
|
+
- **What input do they control?** [enumerate every attacker-reachable input: HTTP params, headers, request body, file uploads, CLI args, env vars, config files, message-queue payloads, RPC arguments, filenames, database rows, etc.]
|
|
9
|
+
- **Privilege boundary being crossed:** [what does the attacker gain by compromising this surface — code execution, data exfiltration, lateral movement, denial of service, privilege escalation?]
|
|
10
|
+
- **What is NOT in scope:** [explicitly name behaviors that look like H findings but are operator-authority-by-design rather than privilege-boundary violations.]
|
|
11
|
+
|
|
12
|
+
ID prefix: `find`.
|
|
13
|
+
|
|
14
|
+
## Source material
|
|
15
|
+
|
|
16
|
+
[Inline the artifact under audit here — diff, file contents, or both. Follow the chunking guide at `../source-material-section-types.md` for how to structure long artifacts. Each line cited in a finding must be reachable from the inlined material.]
|
|
17
|
+
|
|
18
|
+
## Sub-buckets (each requires Shape A finding OR Shape B with ≥3 adversarial probes)
|
|
19
|
+
|
|
20
|
+
**H1. SQL injection**
|
|
21
|
+
- Surface check: any SQL driver, ORM, or query-builder reachable from attacker-controlled input?
|
|
22
|
+
- Shape A pattern: string concatenation / f-string / `%`-formatting building a query that includes attacker input; ORM `raw()` / `execute()` with interpolated text; dynamic table or column names from request input.
|
|
23
|
+
- Shape B probes (when no SQL surface exists): (1) full-text scan for `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `execute(`, `executemany(`, `.raw(`, ORM imports. (2) Scan for any string built with `+`, `%`, or f-string that could later flow into a SQL driver via an imported helper. (3) Verify no template-string construction in scope flows into a SQL pathway through indirect imports or dynamic dispatch.
|
|
24
|
+
|
|
25
|
+
**H2. Command injection**
|
|
26
|
+
- Surface check: any `subprocess`, `os.system`, `os.popen`, `shell=True`, backticks, `Invoke-Expression`, PowerShell `-Command` with interpolated input, `eval`-on-shell-string, or template-into-shell pattern reachable from attacker input?
|
|
27
|
+
- Shape A pattern: f-string / format / concat building a shell command line that includes attacker-influenced text; `subprocess.run(..., shell=True)` with composed argv; PowerShell `-Command "…$var…"` with interpolated variables; argv-shape drift where the command line is parsed twice (once by the shell, once by the C runtime / `CommandLineToArgvW`).
|
|
28
|
+
- Shape B probes (when no shell surface exists): (1) grep for `subprocess.`, `os.system`, `os.popen`, `shell=True`, `Invoke-Expression`, `-Command`, `pty.spawn`, `commands.getoutput`. (2) Verify no library import indirectly invokes a shell (e.g., `git.Repo(..., shell=True)`-style wrappers). (3) Confirm any process-launch site uses argv-list form with no string interpolation into argv[0] or argv[1].
|
|
29
|
+
|
|
30
|
+
**H3. Path traversal**
|
|
31
|
+
- Surface check: any filesystem operation (`open`, `os.walk`, `os.rmdir`, `Path(…).read_text`, `shutil.copy`, `Get-Content`, `Test-Path`, `Remove-Item`) whose path is built from attacker-controlled input?
|
|
32
|
+
- Shape A pattern: user input joined to a base path without `realpath`/`normpath` and without a containment check verifying the resolved path stays under the intended root; symlink-following enabled where it shouldn't be; UNC / device-namespace paths (`\\?\`, `\\.\`) accepted without filtering; trailing-dot or trailing-space Windows pathname tricks.
|
|
33
|
+
- Shape B probes (when no traversal surface exists): (1) `os.walk` / equivalent does not follow symlinks (`followlinks=False` in Python; `-NoFollowSymlink` semantics in PowerShell). (2) UNC / drive-letter / reparse-point handling — document whether the artifact honors them as given (operator authority) or rejects them. (3) Path normalization — does the code call `realpath`/`normpath` before filesystem ops? (4) TOCTOU between any pre-flight check (`isdir`, `Test-Path`) and the actual filesystem op. (5) Pre-flight gate identification — what is the only validation between attacker input and the filesystem syscall?
|
|
34
|
+
|
|
35
|
+
**H4. Authentication bypass**
|
|
36
|
+
- Surface check: any HTTP / RPC / IPC entry point that should require authentication?
|
|
37
|
+
- Shape A pattern: missing auth decorator on a sensitive route; auth check that compares to a constant short-circuit; cookie or token validation that trusts a client-supplied claim without verification; session fixation; auth gated only by client-side state.
|
|
38
|
+
- Shape B probes (when no auth surface exists): (1) grep for `auth`, `token`, `session`, `cookie`, `bearer`, `Authorization`, `password`, `credential`, `@login_required`, equivalent decorators. (2) Confirm no network listener is opened by the artifact (`socket.bind`, `http.server`, `Flask`, `FastAPI`, `aiohttp`, `Express`, `grpc.server`). (3) Confirm any privileged action (`-User`, `-RunLevel Highest`, `setuid`, sudo wrappers) is invoked with a static principal, not from attacker input.
|
|
39
|
+
|
|
40
|
+
**H5. Authorization checks**
|
|
41
|
+
- Surface check: vertical (admin vs user) and horizontal (user A vs user B) access controls on every state-changing or data-reading operation reachable by an authenticated caller.
|
|
42
|
+
- Shape A pattern: missing `is_admin` / role check; ownership lookup that trusts a caller-supplied `user_id` without comparing to the session principal; IDOR — incrementing-id resource access with no per-resource ownership check; tenant-isolation gap where one tenant's request reaches another tenant's row.
|
|
43
|
+
- Shape B probes (when no authorization surface exists): (1) grep for `is_admin`, `role`, `permission`, `owner_id`, `tenant`, `IDOR`, `current_user`, framework-specific role decorators. (2) Confirm state-changing operations are not gated only by URL knowledge or session membership without per-resource ownership. (3) Confirm no privilege escalates between caller principal and operation principal (e.g., a process registered to run as a different user than the registering caller).
|
|
44
|
+
|
|
45
|
+
**H6. Secret / credential leakage**
|
|
46
|
+
- Surface check: any code path that handles API keys, tokens, passwords, signing keys, database credentials, OAuth refresh tokens, JWTs, or other secrets?
|
|
47
|
+
- Shape A pattern: secret written to a log line, error message, stack trace, env-dump endpoint, telemetry payload, crash report, or test fixture; secret committed to source; secret exposed via verbose error in a non-debug build; secret returned in an HTTP response body to an unauthenticated caller.
|
|
48
|
+
- Shape B probes (when no secret surface exists): (1) grep for `key`, `token`, `secret`, `password`, `credential`, `bearer`, `private_key`, `client_secret`, `api_key`. (2) Verify error paths and exception handlers do not log environment, headers, or full request state. (3) Verify no `Get-Credential`, `ConvertFrom-SecureString`, `keyring.*`, secret-manager SDK call persists secrets to disk in plaintext.
|
|
49
|
+
|
|
50
|
+
**H7. SSRF / external request validation**
|
|
51
|
+
- Surface check: any outbound HTTP / network call (`requests.*`, `urllib.*`, `httpx.*`, `Invoke-WebRequest`, `Invoke-RestMethod`, `WebClient`, `HttpClient`, `fetch`, `axios`, `socket.connect`) whose URL or host is built from attacker input?
|
|
52
|
+
- Shape A pattern: URL parameter from request flowing into an outbound `requests.get` without an allowlist; protocol smuggling (`file://`, `gopher://`, `ftp://`); host parsing that diverges from the eventual connect (e.g., `urlparse` vs DNS resolution); cloud-metadata endpoint (`169.254.169.254`, `fd00:ec2::254`) reachable; redirect-following past the validated URL.
|
|
53
|
+
- Shape B probes (when no outbound network surface exists): (1) grep for `requests.`, `urllib`, `http.client`, `Invoke-WebRequest`, `Invoke-RestMethod`, `WebClient`, `HttpClient`, `fetch(`, `axios.`. (2) Verify any auxiliary network-adjacent call (`Get-Command`, `which`, `nslookup`) does not perform an attacker-influenced HTTP request. (3) Verify cloud-metadata endpoints (`169.254.169.254`, `metadata.google.internal`) are not mentioned and not reachable from any code path in scope.
|
|
54
|
+
|
|
55
|
+
**H8. CSRF / state-changing without token**
|
|
56
|
+
- Surface check: any state-changing HTTP handler (POST, PUT, DELETE, PATCH) reachable by an authenticated browser session?
|
|
57
|
+
- Shape A pattern: state-changing handler with no CSRF token validation; SameSite-cookie assumption used as the sole CSRF defense without verifying the framework actually sets it; pre-flight CORS check trusted as authentication; same-origin assumed without enforcement.
|
|
58
|
+
- Shape B probes (when no CSRF surface exists): (1) confirm no `@app.route`-style POST handler, no `@router.post`, no `flask.Flask`, no `fastapi.FastAPI`, no `aiohttp.web.RouteTableDef`, no Express `app.post`. (2) Confirm any local trigger surface (named pipe, Unix socket, COM endpoint, scheduled task) is local-only and not reachable by a remote unauthenticated caller. (3) Confirm no inter-process listener exists that an unprivileged caller could poke to trigger the state change.
|
|
59
|
+
|
|
60
|
+
**H9. Deserialization**
|
|
61
|
+
- Surface check: any code path that deserializes attacker-controllable bytes via a format that supports arbitrary code execution or object instantiation?
|
|
62
|
+
- Shape A pattern: `pickle.loads`, `marshal.loads`, `yaml.load` (without `SafeLoader`), `eval`, `exec`, `Import-Clixml`, `BinaryFormatter`, `ObjectInputStream`, `JsonConvert.DeserializeObject` with `TypeNameHandling.All`, against attacker-controllable bytes.
|
|
63
|
+
- Shape B probes (when no deserialization surface exists): (1) grep for `pickle`, `yaml.load`, `marshal`, `eval(`, `exec(`, `Import-Clixml`, `Deserialize-PSObject`, `BinaryFormatter`, `ObjectInputStream`. (2) Verify any JSON parser is invoked safely (`json.loads` without `object_hook` from attacker input; `JsonConvert` without `TypeNameHandling`). (3) Verify CLI / config parsing (`argparse.parse_args`, `configparser`, `tomllib`) does not deserialize beyond string typing.
|
|
64
|
+
|
|
65
|
+
**H10. File upload / MIME validation**
|
|
66
|
+
- Surface check: any code path that accepts a file from an attacker (multipart upload, paste-from-URL, stream-to-disk)?
|
|
67
|
+
- Shape A pattern: trusted Content-Type from client; missing extension allowlist; missing magic-byte verification; filename used directly as on-disk path; MIME-sniffing differences between server and downstream renderer; archive extraction without zip-slip protection.
|
|
68
|
+
- Shape B probes (when no upload surface exists): (1) grep for `multipart`, `UploadFile`, `request.files`, `werkzeug.FileStorage`, `fastapi.UploadFile`, `Content-Type`, `magic` (libmagic), `zipfile.extractall`. (2) Confirm the only filesystem-write operations in scope target paths the artifact controls, not paths derived from attacker input. (3) Confirm no archive (zip / tar / 7z) is extracted with attacker-supplied member paths.
|
|
69
|
+
|
|
70
|
+
## Cross-bucket questions to answer at the end
|
|
71
|
+
|
|
72
|
+
Q1: Are there any inputs that cross two H sub-buckets? (e.g., a path that flows through H3-style filesystem handling AND becomes an interpolated argument in an H2 shell command — are the two trust assumptions consistent across both sites?)
|
|
73
|
+
Q2: What's the worst injection / leakage hazard introduced by this artifact? Cite `<file>:<line>` for the specific construction.
|
|
74
|
+
Q3: Which input is most fragile to a future API addition — i.e., where would a future change most likely turn an operator-trust assumption into an actual attacker-reachable surface? Name the line(s) most likely to break.
|
|
75
|
+
|
|
76
|
+
## Output
|
|
77
|
+
|
|
78
|
+
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket H1-H10, produce Shape A or Shape B (with ≥3 probes). Cross-bucket Q1-Q3 answers after the per-sub-bucket walk. Adversarial second pass: "assume your first pass missed at least 3 P0/P1 vulnerabilities across these 10 sub-buckets — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
|
|
79
|
+
|
|
80
|
+
Note: Category H findings tend toward P0/P1 since they're security-relevant — adjust the adversarial-pass quota severity accordingly. If the artifact's trust model caps realistic findings below P1 (e.g., operator-only invocation with no remote attacker surface), the adversarial pass should still hunt P0/P1 by asking "what changes if this code is ever invoked from an untrusted context — a CI runner, a different user's session, a remote-management tool, a future HTTP wrapper?"
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
# Worked example: jl-cmd/claude-code-config PR #394
|
|
85
|
+
|
|
86
|
+
Audit jl-cmd/claude-code-config PR #394 for **Category H only** (security boundaries). Skip A–G, I–K. Sub-bucket forced-exhaustion mode: Category H is decomposed into 10 sub-buckets below. Each sub-bucket REQUIRES at least one Shape A finding OR exactly one Shape B proof-of-absence with **at least 3 adversarial probes** specific to that sub-bucket. A sub-bucket returning neither is a protocol gap.
|
|
87
|
+
|
|
88
|
+
PR: feat(scripts): add sweep-empty-dirs utility and scheduled-task installer
|
|
89
|
+
Head SHA: 62c9c169ee7a44824e5da25c4cf8b74fdca08a53
|
|
90
|
+
ID prefix: `find`.
|
|
91
|
+
|
|
92
|
+
## ARTIFACT METADATA — trust model
|
|
93
|
+
|
|
94
|
+
This PR adds a directory-sweeping utility plus a Windows scheduled-task installer that registers it. The script is invoked locally by an operator as a scheduled task; there is no network listener, no inbound HTTP, no external callers.
|
|
95
|
+
|
|
96
|
+
- **Operator-controlled inputs** (the only inputs that exist):
|
|
97
|
+
- `arguments.root` — CLI positional, free-form path string.
|
|
98
|
+
- `arguments.age` — CLI int (`--age`), feeds `time.time()` arithmetic and `min_age_seconds` comparison.
|
|
99
|
+
- `arguments.interval` — CLI int (`--interval`), feeds `time.sleep`.
|
|
100
|
+
- `--once` — CLI switch (bool).
|
|
101
|
+
- PowerShell installer parameters: `$Target` (path string), `$IntervalMinutes` (int), `$AgeSeconds` (int), `$Remove` / `$Status` switches.
|
|
102
|
+
- **Privilege:** the script runs as whatever user the scheduled task is registered under (typically the operator's own account; potentially SYSTEM if `Register-ScheduledTask` is invoked from an elevated session).
|
|
103
|
+
- **Attacker model:** there is no remote attacker surface. The interesting H surfaces in this PR are (a) operator-error blast radius — does the script honor or silently expand the path the operator gave? — and (b) shell-injection-via-test-helper, where a future test author could pass a path containing a single quote into a PowerShell single-quoted literal and break the string. Authentication, authorization, SSRF, CSRF, deserialization, and file-upload sub-buckets are Shape B (no surface) for this artifact.
|
|
104
|
+
- **What's NOT in scope here:** the operator deliberately providing `--age 0` against `C:\` to wipe their own machine. That is operator authority being used as designed, not a privilege-boundary violation.
|
|
105
|
+
|
|
106
|
+
## Sub-buckets (each requires Shape A finding OR Shape B with ≥3 adversarial probes)
|
|
107
|
+
|
|
108
|
+
**H1. SQL injection**
|
|
109
|
+
- The PR introduces no SQL, no ORM, no database driver, no `sqlite3` / `psycopg2` / `sqlalchemy` import.
|
|
110
|
+
- Shape B probes: (1) full-text scan all 4 inlined files for `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `execute(`, `executemany(`, `.raw(`. (2) Scan for any string built with `+`, `%`, or f-string that could later be passed to a SQL driver. (3) Verify the only string-template construction in the diff (PowerShell command at the test helper, `New-ScheduledTaskAction -Argument` at the installer) does not flow into a SQL pathway via any imported helper.
|
|
111
|
+
|
|
112
|
+
**H2. Command injection** ⭐ canonical H surface for this PR
|
|
113
|
+
This sub-bucket has TWO distinct command-string-build sites in the diff, both with operator-trust assumptions worth naming explicitly.
|
|
114
|
+
|
|
115
|
+
- **Site 1 — test helper f-string into a PowerShell single-quoted literal.** `test_sweep_empty_dirs.py:25` builds `f"(Get-Item '{path}').CreationTimeUtc = [DateTime]'{date_str}'"` and passes it as the argument to `subprocess.run(["powershell", "-Command", ...], check=True, capture_output=True)`.
|
|
116
|
+
- PowerShell single-quoted strings DO NOT honor backslash escapes, so `\` in `path` is fine. They DO terminate at the first unescaped single quote (`'`), and PowerShell's escape inside a single-quoted literal is a doubled single quote (`''`), which f-string interpolation does not produce.
|
|
117
|
+
- NTFS legitimately permits `'` in directory names. A directory like `O'Brien-temp` would terminate the literal early, leave `).CreationTimeUtc = [DateTime]'…` as live PowerShell to execute, and break the test (or, in the wrong hands, execute injected commands under the operator's identity).
|
|
118
|
+
- Trust assumption that defends this: `tempfile.TemporaryDirectory()` produces a system-temp path and a generated 8-char random suffix. The system-temp ancestor (`C:\Users\<user>\AppData\Local\Temp`) is operator-controlled but stable; the generated suffix uses `string.ascii_letters + string.digits + "_"` (CPython `tempfile._RandomNameSequence`) — no single quotes. So under normal operation no quote ever appears.
|
|
119
|
+
- Severity P2 in this context: it's test-only code, locally bounded, and an operator who renames `Temp` to a path with a single quote has bigger problems. But the bullet is a Shape A finding because the f-string-into-single-quoted-PowerShell pattern itself is fragile and a future caller (e.g., a parametrized test that takes a path argument) could break it.
|
|
120
|
+
- Alternative fix shape worth naming in the report: invoke via the cmdlet's named-argument argv — `subprocess.run(["powershell", "-NoProfile", "-Command", "param([string]$Path,[string]$Date) (Get-Item -LiteralPath $Path).CreationTimeUtc = [DateTime]$Date", "-Path", path, "-Date", date_str], ...)` — moves `path` and `date_str` out of the source string and into argv slots PowerShell parses safely.
|
|
121
|
+
|
|
122
|
+
- **Site 2 — installer PowerShell argument-string for the registered scheduled task.** `Install-SweepEmptyDirs.ps1:69` builds `$Action = New-ScheduledTaskAction -Execute $PythonPath -Argument "$ScriptPath --once --age $AgeSeconds ""$Target"""`.
|
|
123
|
+
- PowerShell double-quoted strings expand `$` variables and use doubled `"` (`""`) as the embedded-quote escape, which is what's used here for `$Target`.
|
|
124
|
+
- The operator-supplied `$Target` is interpolated INTO that double-quoted PowerShell string AND the resulting string is later parsed as Windows process argv by the C runtime when the scheduled task launches `python.exe …`. Two parsers, two escape contexts.
|
|
125
|
+
- **Trailing-backslash hazard (Microsoft C-runtime argv parser):** if `$Target` ends with `\` (which is allowed; the operator typed `Y:\Some Folder\`), the `\"` sequence at the end of the string can be interpreted by `CommandLineToArgvW` as an escaped quote rather than a closing quote, merging the trailing `"` into the path token. The downstream `arguments.root` string would then contain a literal `"`. Severity P2 — the resulting `os.path.isdir(arguments.root)` check at sweep_empty_dirs.py main() rejects nonexistent paths and exits non-zero; failure mode is "task runs and immediately fails with a clear error" rather than silent compromise.
|
|
126
|
+
- **Embedded-quote hazard:** if `$Target` itself contains a `"` (NTFS forbids `"` in names, so this is not reachable on Windows-native filesystems; reachable if a SMB share maps in a UNC path that originated on a non-Windows server). Document as "guarded by NTFS naming rules; not a current vulnerability."
|
|
127
|
+
- **Embedded-`$` hazard:** PowerShell expands `$` inside double-quoted strings at the time `New-ScheduledTaskAction -Argument "…"` is evaluated. If `$Target` contains a literal `$Foo` substring, `$Foo` is expanded against the installer's variable scope at registration time — empty string by default, so the registered task gets a corrupted path. Cite as a Shape A finding; the safer pattern is to build the argument with `[string]::Join` and a backtick-escape, or to register the task with separate `-Execute` and `-Argument` slots that quote the path via `'`-delimited single-quoted PowerShell literal at install time.
|
|
128
|
+
- **Argv-shape drift:** `--once --age $AgeSeconds "$Target"` puts `--once` and `--age` BEFORE the positional `root`. The argparse parser at sweep_empty_dirs.py (`_build_parser`) accepts this ordering, so it works today. A future argparse refactor that adds `parser.add_argument("root", nargs=argparse.OPTIONAL)` or makes `root` a sub-command name would silently reorder requirements; the installer's argv would still validate at PowerShell-string time but would mis-route at Python-time.
|
|
129
|
+
|
|
130
|
+
- Cross-shape probe for both sites: search the full diff for ANY other f-string, `Format-Operator -f`, `[string]::Format`, or string-concat pattern that builds shell-bound text. Confirm sites 1 and 2 are the only two.
|
|
131
|
+
|
|
132
|
+
**H3. Path traversal**
|
|
133
|
+
- `arguments.root` (CLI positional) flows directly into `os.walk(arguments.root, onerror=_log_walk_error, topdown=False)` and from there each `each_directory_path` flows into `os.rmdir(each_directory_path)`.
|
|
134
|
+
- Path traversal as classically defined (a remote attacker submitting `../../etc/passwd` to escape a containment root) is **Shape B not applicable**: the script IS the privileged process — there is no containment root. Whatever path the operator gives is exactly the path that gets walked. The trust assumption is "operator provides correct root."
|
|
135
|
+
- Adversarial probes: (1) symlink-following — `os.walk` does NOT follow symlinks by default (`followlinks=False`), so a sibling symlink under `arguments.root` pointing to `C:\Windows\Temp` is not traversed. Confirmed safe. (2) UNC paths — `os.walk(r"\\server\share")` works on Windows and is honored as given; if the operator types a UNC path, the script walks it. Document as operator authority. (3) path normalization — the script does NOT call `os.path.realpath` or `os.path.normpath` before `os.walk`. A path like `Y:\Projects\..\Projects\foo` is honored literally by `os.walk` (Windows resolves it via `GetFullPathName` at syscall time anyway). No additional risk introduced. (4) `os.path.isdir(arguments.root)` is the only pre-flight gate — if the operator provides a file path (not a directory) the script exits 1; if they provide a non-existent path it exits 1; if they provide a directory under a junction or reparse point, it walks it. (5) Race: between the `os.path.isdir` check at main() and the `os.walk` call, the operator could swap the path for a symlink to a different tree (TOCTOU). Operator-only attack surface; not exploitable remotely.
|
|
136
|
+
- Severity for the unverified-realpath observation: P3 / accepted-trust. The reviewer should note "no realpath; trusts operator's literal root" and move on; introducing a containment root would constrain the script's stated purpose.
|
|
137
|
+
|
|
138
|
+
**H4. Authentication bypass**
|
|
139
|
+
- This artifact has zero authentication surface. There is no HTTP server, no token check, no session, no cookie, no API key parsing, no `Authorization:` header reading, no credential validation flow.
|
|
140
|
+
- Shape B probes: (1) grep all 4 files for `auth`, `token`, `session`, `cookie`, `bearer`, `Authorization`, `password`, `credential`. Expect zero matches. (2) Verify the PowerShell installer does not register the task with `-User` / `-RunLevel Highest` from operator input — confirmed: `Register-ScheduledTask` is called without `-User` or `-Password`, defaulting to the current interactive user at install time. (3) Verify nothing in the diff exposes a network port (`socket.bind`, `http.server`, `Flask`, `FastAPI`, `aiohttp`).
|
|
141
|
+
|
|
142
|
+
**H5. Authorization checks**
|
|
143
|
+
- This artifact has no concept of users, roles, tenants, or per-resource ownership. There is no vertical-privilege check (admin vs user) and no horizontal-privilege check (user A vs user B).
|
|
144
|
+
- Shape B probes: (1) full-text grep for `is_admin`, `role`, `permission`, `owner_id`, `tenant`, `IDOR`. Expect zero matches. (2) Verify `os.rmdir` is not gated by an ownership check — confirmed it is not, but this is by design: the script runs with the operator's authority and deletes whatever empty directories that authority can reach. (3) Confirm the registered scheduled task does not run as a more-privileged principal than the installer — `Register-ScheduledTask` without an explicit `-User` runs as the registering user, so privilege does not escalate.
|
|
145
|
+
|
|
146
|
+
**H6. Secret / credential leakage**
|
|
147
|
+
- This PR handles zero secrets. No API keys, no tokens, no passwords, no database credentials, no signing keys, no environment-dump endpoints.
|
|
148
|
+
- Shape B probes: (1) grep all 4 files for `key`, `token`, `secret`, `password`, `credential`, `bearer`. Result: two hits in `Install-SweepEmptyDirs.ps1` for `-TaskName` (false-positive — substring match on `name`), zero credential matches. (2) Verify error paths in `sweep_empty_dirs.py` (`_log_walk_error`, the `except OSError: pass` blocks) do not log environment or trace state — confirmed they log only `os_error.filename` and `os_error.strerror`. (3) Verify the PowerShell installer does not call `Get-Credential`, `ConvertFrom-SecureString`, or persist secrets to the task XML — confirmed.
|
|
149
|
+
|
|
150
|
+
**H7. SSRF / external request validation**
|
|
151
|
+
- The artifact makes zero outbound network requests. No `requests.*`, no `urllib.*`, no `httpx.*`, no `Invoke-WebRequest`, no `Invoke-RestMethod`, no `socket.*` connect calls.
|
|
152
|
+
- Shape B probes: (1) grep all 4 files for `requests.`, `urllib`, `http.client`, `Invoke-WebRequest`, `Invoke-RestMethod`, `WebClient`, `HttpClient`. Expect zero matches. (2) Verify `Get-Command` (the only network-adjacent PowerShell call) only resolves a binary on PATH — it does not perform DNS or HTTP. (3) Verify the cloud-metadata endpoint (169.254.169.254) is not mentioned and not reachable from any code path in the diff.
|
|
153
|
+
|
|
154
|
+
**H8. CSRF / state-changing without token**
|
|
155
|
+
- Not applicable: there is no HTTP handler, no form, no same-origin assumption, no browser-mediated POST. The script's state-changing operation (`os.rmdir`) is invoked from a local Python process, not from a browser-issued HTTP request.
|
|
156
|
+
- Shape B probes: (1) confirm no `@app.route`, no `@router.post`, no `flask.Flask`, no `fastapi.FastAPI`, no `aiohttp.web.RouteTableDef`. (2) Confirm the scheduled-task trigger surface (`Register-ScheduledTask`) is local-only and not reachable by a remote unauthenticated caller. (3) Confirm there's no inter-process listener (named pipe, Unix socket, COM endpoint) that an unprivileged caller could poke to trigger sweeps.
|
|
157
|
+
|
|
158
|
+
**H9. Deserialization**
|
|
159
|
+
- The artifact never deserializes attacker-controllable bytes. No `pickle.loads`, no `yaml.load`, no `marshal.loads`, no `eval`, no `exec`, no `json.loads(..., object_hook=…)`. No `Import-Clixml`, no `Deserialize-PSObject`.
|
|
160
|
+
- Shape B probes: (1) grep all 4 files for `pickle`, `yaml`, `marshal`, `eval(`, `exec(`, `Import-Clixml`, `ConvertFrom-Json` (note: `ConvertFrom-Json` is a JSON parser, generally safe; verify it isn't called against operator input). Expect zero matches. (2) Verify the PowerShell installer does not deserialize task XML from operator-controlled paths. (3) Verify `argparse.parse_args()` behavior — it does not deserialize; it only string-types CLI tokens.
|
|
161
|
+
|
|
162
|
+
**H10. File upload / MIME validation**
|
|
163
|
+
- Not applicable: the artifact accepts no file uploads. There is no HTTP multipart handler, no `werkzeug.FileStorage`, no `flask.request.files`, no `fastapi.UploadFile`, no `Save-Item`-from-stream pathway.
|
|
164
|
+
- Shape B probes: (1) grep all 4 files for `multipart`, `UploadFile`, `request.files`, `werkzeug`, `Content-Type`, `magic` (libmagic). Expect zero matches. (2) Confirm the only file-system operations in the diff are read (`Test-Path`, `os.walk`, `os.path.getctime`, `os.path.isdir`) and delete-empty (`os.rmdir`) — there is no file-write operation introduced by this PR. (3) Confirm `Path(nonempty_dir, "keepme.txt").write_text("hello")` in the test file at `test_sweep_empty_dirs.py` creates fixture content under `tempfile.TemporaryDirectory`, not under operator-controlled input.
|
|
165
|
+
|
|
166
|
+
## Cross-bucket questions to answer at the end
|
|
167
|
+
|
|
168
|
+
Q1: Are there any inputs that cross two H sub-buckets? (For PR #394, the candidate is `arguments.root` flowing through H3-style path-handling code that ALSO becomes the `$Target` interpolated into the H2 PowerShell argument string when an operator re-runs the installer with the same path. Are the two trust assumptions consistent?)
|
|
169
|
+
Q2: What's the worst injection / leakage hazard introduced by this PR? Cite `<file>:<line>` for the specific construction. (Candidate: `Install-SweepEmptyDirs.ps1:69` `New-ScheduledTaskAction -Argument` interpolating `$Target` — embedded-`$` and trailing-backslash hazards are both reachable in normal Windows path-naming.)
|
|
170
|
+
Q3: Which input is most fragile to a future API addition — i.e., where would a future change most likely turn an operator-trust assumption into an actual attacker-reachable surface? Name the line(s) most likely to break. (Candidate: `test_sweep_empty_dirs.py:25` `_set_creation_time_windows` — if a future test parametrizes `path` from a non-tempfile source, the f-string-into-single-quoted-PowerShell pattern flips from "fragile-but-bounded" to "exploitable.")
|
|
171
|
+
|
|
172
|
+
## Output
|
|
173
|
+
|
|
174
|
+
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket H1-H10, produce Shape A or Shape B (with ≥3 probes). Cross-bucket Q1-Q3 answers after the per-sub-bucket walk. Adversarial second pass: "assume your first pass missed at least 3 P0/P1 vulnerabilities across these 10 sub-buckets — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
|
|
175
|
+
|
|
176
|
+
Note: Category H findings tend toward P0/P1 since they're security-relevant — adjust the adversarial-pass quota severity accordingly. For PR #394 specifically, the trust model (no remote attacker, operator-only invocation) caps most realistic findings at P2; the adversarial pass should still hunt P0/P1 by asking "what changes if this script is ever invoked from an untrusted context — a CI runner, a different user's scheduled task, a remote-management tool?"
|
|
177
|
+
|
|
178
|
+
## Diff (4 new files, all lines in scope)
|
|
179
|
+
|
|
180
|
+
### packages/claude-dev-env/scripts/sweep_empty_dirs.py
|
|
181
|
+
```python
|
|
182
|
+
#!/usr/bin/env python3
|
|
183
|
+
"""Delete empty directories older than 2 minutes under a given root."""
|
|
184
|
+
|
|
185
|
+
import argparse
|
|
186
|
+
import os
|
|
187
|
+
import sys
|
|
188
|
+
import time
|
|
189
|
+
|
|
190
|
+
from config.sweep_config import DEFAULT_AGE_SECONDS
|
|
191
|
+
from config.sweep_config import DEFAULT_POLL_INTERVAL
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _log_walk_error(os_error: OSError) -> None:
|
|
195
|
+
print(f"warning: cannot scan {os_error.filename} — {os_error.strerror}", file=sys.stderr)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def sweep(root: str, min_age_seconds: int) -> list[str]:
|
|
199
|
+
"""Remove empty directories under *root* older than *min_age_seconds*."""
|
|
200
|
+
|
|
201
|
+
now = time.time()
|
|
202
|
+
removed: list[str] = []
|
|
203
|
+
|
|
204
|
+
for each_directory_path, _, _ in os.walk(
|
|
205
|
+
root, onerror=_log_walk_error, topdown=False
|
|
206
|
+
):
|
|
207
|
+
try:
|
|
208
|
+
created = os.path.getctime(each_directory_path)
|
|
209
|
+
except OSError:
|
|
210
|
+
continue
|
|
211
|
+
if now - created >= min_age_seconds:
|
|
212
|
+
try:
|
|
213
|
+
os.rmdir(each_directory_path)
|
|
214
|
+
print(f"deleted: {each_directory_path}")
|
|
215
|
+
removed.append(each_directory_path)
|
|
216
|
+
except OSError:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
return removed
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
223
|
+
parser = argparse.ArgumentParser(description="Delete empty directories older than a given age.")
|
|
224
|
+
parser.add_argument("root", help="Root directory to scan")
|
|
225
|
+
parser.add_argument("--age", type=int, default=DEFAULT_AGE_SECONDS,
|
|
226
|
+
help=f"Minimum age in seconds (default: {DEFAULT_AGE_SECONDS} = 2 minutes)")
|
|
227
|
+
parser.add_argument("--once", action="store_true",
|
|
228
|
+
help="Single pass and exit instead of watching in a loop")
|
|
229
|
+
parser.add_argument("--interval", type=int, default=DEFAULT_POLL_INTERVAL,
|
|
230
|
+
help=f"Poll interval in seconds when looping (default: {DEFAULT_POLL_INTERVAL})")
|
|
231
|
+
return parser
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def main() -> None:
|
|
235
|
+
parser = _build_parser()
|
|
236
|
+
arguments = parser.parse_args()
|
|
237
|
+
|
|
238
|
+
if not os.path.isdir(arguments.root):
|
|
239
|
+
print(f"error: not a directory: {arguments.root}", file=sys.stderr)
|
|
240
|
+
sys.exit(1)
|
|
241
|
+
|
|
242
|
+
if arguments.once:
|
|
243
|
+
sweep(arguments.root, arguments.age)
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
print(f"watching {arguments.root} every {arguments.interval}s (age threshold: {arguments.age}s)")
|
|
247
|
+
try:
|
|
248
|
+
while True:
|
|
249
|
+
sweep(arguments.root, arguments.age)
|
|
250
|
+
time.sleep(arguments.interval)
|
|
251
|
+
except KeyboardInterrupt:
|
|
252
|
+
print("\nstopped.")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
main()
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### packages/claude-dev-env/scripts/config/sweep_config.py
|
|
260
|
+
```python
|
|
261
|
+
"""Centralized timing configuration for sweep_empty_dirs."""
|
|
262
|
+
|
|
263
|
+
DEFAULT_AGE_SECONDS: int = 120
|
|
264
|
+
DEFAULT_POLL_INTERVAL: int = 30
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### packages/claude-dev-env/scripts/tests/test_sweep_empty_dirs.py
|
|
268
|
+
```python
|
|
269
|
+
"""Tests for sweep_empty_dirs.py"""
|
|
270
|
+
|
|
271
|
+
from __future__ import annotations
|
|
272
|
+
|
|
273
|
+
import datetime
|
|
274
|
+
import os
|
|
275
|
+
import subprocess
|
|
276
|
+
import sys
|
|
277
|
+
import tempfile
|
|
278
|
+
import time
|
|
279
|
+
from pathlib import Path
|
|
280
|
+
|
|
281
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
|
282
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
283
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
284
|
+
|
|
285
|
+
from sweep_empty_dirs import sweep # noqa: E402
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _set_creation_time_windows(path: str, timestamp: float) -> None:
|
|
289
|
+
dt = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
|
|
290
|
+
date_str = dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
291
|
+
subprocess.run(
|
|
292
|
+
["powershell", "-Command",
|
|
293
|
+
f"(Get-Item '{path}').CreationTimeUtc = [DateTime]'{date_str}'"],
|
|
294
|
+
check=True, capture_output=True,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_deletes_empty_dir_older_than_threshold() -> None:
|
|
299
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
300
|
+
empty_dir = os.path.join(tmp, "old_empty")
|
|
301
|
+
os.mkdir(empty_dir)
|
|
302
|
+
_set_creation_time_windows(empty_dir, time.time() - 300)
|
|
303
|
+
removed = sweep(tmp, min_age_seconds=120)
|
|
304
|
+
assert empty_dir in removed
|
|
305
|
+
assert not os.path.isdir(empty_dir)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_skips_empty_dir_newer_than_threshold() -> None:
|
|
309
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
310
|
+
fresh_dir = os.path.join(tmp, "fresh_empty")
|
|
311
|
+
os.mkdir(fresh_dir)
|
|
312
|
+
removed = sweep(tmp, min_age_seconds=120)
|
|
313
|
+
assert fresh_dir not in removed
|
|
314
|
+
assert os.path.isdir(fresh_dir)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_deletes_nested_empty_dirs() -> None:
|
|
318
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
319
|
+
leaf = os.path.join(tmp, "parent", "child", "leaf")
|
|
320
|
+
os.makedirs(leaf)
|
|
321
|
+
_set_creation_time_windows(os.path.join(tmp, "parent"), time.time() - 300)
|
|
322
|
+
_set_creation_time_windows(os.path.join(tmp, "parent", "child"), time.time() - 300)
|
|
323
|
+
_set_creation_time_windows(leaf, time.time() - 300)
|
|
324
|
+
removed = sweep(tmp, min_age_seconds=120)
|
|
325
|
+
assert leaf in removed
|
|
326
|
+
assert os.path.join(tmp, "parent", "child") in removed
|
|
327
|
+
assert os.path.join(tmp, "parent") in removed
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_empty_root_does_not_crash() -> None:
|
|
331
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
332
|
+
_set_creation_time_windows(tmp, time.time() - 300)
|
|
333
|
+
sweep(tmp, min_age_seconds=120)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_skips_nonempty_dir() -> None:
|
|
337
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
338
|
+
nonempty_dir = os.path.join(tmp, "has_stuff")
|
|
339
|
+
os.mkdir(nonempty_dir)
|
|
340
|
+
Path(nonempty_dir, "keepme.txt").write_text("hello")
|
|
341
|
+
removed = sweep(tmp, min_age_seconds=0)
|
|
342
|
+
assert nonempty_dir not in removed
|
|
343
|
+
assert os.path.isdir(nonempty_dir)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### packages/claude-dev-env/scripts/Install-SweepEmptyDirs.ps1
|
|
347
|
+
```powershell
|
|
348
|
+
#!/usr/bin/env pwsh
|
|
349
|
+
param(
|
|
350
|
+
[Parameter(ParameterSetName = "install")]
|
|
351
|
+
[string]$Target,
|
|
352
|
+
|
|
353
|
+
[Parameter(ParameterSetName = "install")]
|
|
354
|
+
[int]$IntervalMinutes = 5,
|
|
355
|
+
|
|
356
|
+
[Parameter(ParameterSetName = "install")]
|
|
357
|
+
[int]$AgeSeconds = 120,
|
|
358
|
+
|
|
359
|
+
[Parameter(ParameterSetName = "remove")]
|
|
360
|
+
[switch]$Remove,
|
|
361
|
+
|
|
362
|
+
[Parameter(ParameterSetName = "status")]
|
|
363
|
+
[switch]$Status
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
$TaskName = "SweepEmptyDirs"
|
|
367
|
+
|
|
368
|
+
if ($Status) {
|
|
369
|
+
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
370
|
+
if (-not $task) {
|
|
371
|
+
Write-Host "STATUS: $TaskName is not registered."
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
Write-Host "STATUS: $TaskName is registered."
|
|
375
|
+
Write-Host " State: $($task.State)"
|
|
376
|
+
Write-Host " Actions:"
|
|
377
|
+
foreach ($action in $task.Actions) {
|
|
378
|
+
Write-Host " $($action.Execute) $($action.Arguments)"
|
|
379
|
+
}
|
|
380
|
+
Write-Host " Triggers:"
|
|
381
|
+
foreach ($trigger in $task.Triggers) {
|
|
382
|
+
Write-Host " $($trigger.Repetition.Interval) (starting $($trigger.StartBoundary))"
|
|
383
|
+
}
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if ($Remove) {
|
|
388
|
+
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
389
|
+
Write-Host "$TaskName removed."
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
$ScriptDir = Split-Path -Parent $PSCommandPath
|
|
394
|
+
$ScriptPath = Join-Path $ScriptDir "sweep_empty_dirs.py"
|
|
395
|
+
|
|
396
|
+
if (-not (Test-Path $ScriptPath)) {
|
|
397
|
+
Write-Error "sweep_empty_dirs.py not found at: $ScriptPath"
|
|
398
|
+
exit 1
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (-not $Target) {
|
|
402
|
+
Write-Error "Parameter -Target is required (the directory to watch)."
|
|
403
|
+
exit 1
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (-not (Test-Path $Target)) {
|
|
407
|
+
Write-Error "Target directory does not exist: $Target"
|
|
408
|
+
exit 1
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
$_py = Get-Command py -ErrorAction SilentlyContinue
|
|
412
|
+
$PythonPath = if ($_py) { $_py.Source } else { (Get-Command python).Source }
|
|
413
|
+
if (-not $PythonPath) {
|
|
414
|
+
Write-Error "Cannot find Python (py or python) on PATH."
|
|
415
|
+
exit 1
|
|
416
|
+
}
|
|
417
|
+
$Action = New-ScheduledTaskAction -Execute $PythonPath -Argument "$ScriptPath --once --age $AgeSeconds ""$Target"""
|
|
418
|
+
$Trigger = New-ScheduledTaskTrigger -Daily -At "00:00" -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
|
|
419
|
+
$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
|
420
|
+
|
|
421
|
+
Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings -Force | Out-Null
|
|
422
|
+
Write-Host "$TaskName registered — runs every ${IntervalMinutes}min against '$Target' (age ≥ ${AgeSeconds}s)."
|
|
423
|
+
```
|