Skip to content

feat(security): add deterministic public-surface scrub Claude Code mod (part of #1497) - #1676

Merged
potiuk merged 16 commits into
apache:mainfrom
Kaap10:feat/claude-code-mod-security-scrub-1497
Oct 26, 2026
Merged

potiuk merged 16 commits into
apache:mainfrom
Kaap10:feat/claude-code-mod-security-scrub-1497

Conversation

@Kaap10

@Kaap10 Kaap10 commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Adds a zero-token Claude Code mod in plugins/magpie-security/hooks/ that intercepts Bash commands (git commit, git tag, gh pr create, gh pr edit, gh pr review, gh pr comment, gh issue comment, gh api) and guards public surfaces against accidental security embargo leaks.
  • Intercepts and parses commit messages (-m, -am, -sm, -sam, -F), attached short flags (-m"...", -Fpath), PR descriptions/comments, heredocs, and shell pipelines.
  • Evaluates composite multi-command pipelines per subcommand across all shell separators (;, &&, ||, |, \n).
  • Implements strict regex matching for forbidden embargo terms aligned with plugins/magpie-security/skills/issue-fix/implementation-plan.md:31-43 (CVE references, vulnerability wording, security fix phrasing, and vulnerability classes like SSRF, XSS, CSRF, SQL injection, path traversal, RCE, privilege escalation, auth bypass).
  • Dynamically resolves upstream_repo and tracker_repo from <project-config>/project.md via resolveProjectConfig(), scoping the scrub strictly to upstream_repo and exempting tracker_repo per subcommand without hardcoded naming conventions.
  • Enforces deterministic fail-closed behavior on unreadable message files, --amend/--fill, and indeterminate dynamic inputs ($VAR, $(...), stdin -). Resolves -F / --body-file relative to git -C <dir> without Node built-in imports.
  • Provides a human-set options.env.MAGPIE_ALLOW_EMBARGO_TERMS=1 environment escape hatch for legitimate post-coordinated-disclosure maintenance (in-command bypasses removed).
  • Passes clean behavioral changes through with zero token consumption and zero latency.

Type of change

  • Skill change (.claude/skills/<name>/) — eval fixtures updated below
  • Tool / bridge contract (tools/<system>/*.md)
  • Python package (tools/*/ with pyproject.toml)
  • Groovy reference impl
  • Cross-cutting (RFC, AGENTS.md, sandbox, privacy-LLM)
  • Documentation (docs/, README.md, CONTRIBUTING.md)
  • Project template (projects/_template/)
  • CI / dev loop (prek, workflows, validators)
  • Other: Claude Code mod hooks (plugins/magpie-security/hooks/)

Test plan

  • Ran claude plugin validate --strict plugins/magpie-security (Validation passed, minimal API surface: $.fs.read).
  • Ran claude plugin test plugins/magpie-security (47 pass, 0 fail).
  • Ran claude plugin test plugins/magpie-utilities and plugins/magpie-setup for regression testing (83 total tests pass across repo).
  • Verified prek linters (end-of-file-fixer, trailing-whitespace, mixed-line-ending) pass cleanly.

RFC-AI-0004 compliance

  • HITL — any new mutation is gated on explicit user confirmation
  • Sandbox — minimal declared surface area ($.fs.read), no node:fs or node:path built-in imports, deterministic execution.
  • Vendor neutrality — mod is strictly additive; existing issue-fix skill workflow and Markdown verification checklist remain completely untouched for non-Claude harnesses.
  • Conversational + correctable — agentic-override path documented if behaviour is adopter-tunable
  • Write-access discipline — no autonomous outbound messages; drafts only, sent on confirmation
  • Privacy LLM — zero model calls made; operates 100% locally on the host machine.

Linked issues

Notes for reviewers (optional)

@github-actions github-actions Bot added the family:security security-* skills label Oct 16, 2026
@Kaap10

Kaap10 commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Implemented zero-token deterministic public-surface embargo scrub mod.
Ready for review, please let me know if any adjustments or changes are needed!

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking up a deterministic scrub — the idea is right, but as written the agent can switch the guard off from inside the command it is checking, several ordinary shell shapes get a message past the parser, and the scope (every commit in every repository) is wider than the rule it enforces.

Outputs are probabilistic; gates are deterministic (plugins/magpie-security/hooks/public-surface-scrub.ts:237)

Skills produce drafts. Tool calls enforce schemas. Humans or deterministic checks decide whether a draft becomes state. Probabilistic at the input, deterministic at every state change. The boundary never blurs, even when the draft looks reliable enough to short-circuit the gate.

    /(?:^|\s)MAGPIE_ALLOW_EMBARGO_TERMS=1(?:\s|$)/.test(commandLine) ||
    commandLine.includes('--allow-embargo-terms')

The escape hatch can be switched on by the very command it is meant to stop. An agent that hits the block (and whose reason text tells it how to proceed) only has to prefix MAGPIE_ALLOW_EMBARGO_TERMS=1 or append --allow-embargo-terms anywhere in the command, including inside the commit message or a trailing # comment, and the guard passes it. --allow-embargo-terms is also not a real git or gh flag, so a literal use makes the underlying command fail. A deterministic gate has to be outside the agent's reach.

Suggested fix: honour only options.env (the hook process environment, set by the human before the session), drop the command-line forms, and leave the override out of the blocking message, so the agent is told to ask the maintainer rather than how to bypass the guard.

Flagged by both the primary and adversarial reviewers.

Writing a literal project value directly into a skill is a refactor bug (plugins/magpie-security/hooks/public-surface-scrub.ts:88)

Concretely, gh issue view <N> --repo <tracker> means "substitute
<tracker> for <project-config>/project.md → tracker_repo before
running this". Writing a literal project value directly into a skill is a
refactor bug — skills must stay project-agnostic so swapping projects is
a config change, not a code change.

    repo.endsWith('-s') ||
    repo.includes('-s/') ||
    repo.includes('/private') ||
    repo.includes('-private') ||
    repo.includes('security-tracker')

The private-tracker exemption is a naming guess, and it fails open: any public repository whose name ends in -s or contains -private / /private / security-tracker is exempt, so security wording on that public surface passes unchecked. A tracker named any other way (for example apache/foo-security) gets blocked instead. Only the airflow-s/airflow-s convention is encoded. It also exempts the whole command: isPrivateTrackerTarget searches the entire string and line 118 returns early, so gh pr view 1 -R org/private-tracker; gh pr comment 2 -R apache/airflow -b "CVE-… SSRF" passes although the second command targets the public upstream (found by codex).

Suggested fix: read tracker_repo (and upstream_repo) from the resolved <project-config>/project.md and exempt exactly that slug, and nothing when it cannot be resolved; decide the exemption per sub-command, not for the whole script.

Flagged by both the primary and adversarial reviewers.

This is the single forbidden-term list for every public surface of this skill. (plugins/magpie-security/hooks/public-surface-scrub.ts:128)

This is the single forbidden-term list for every public surface of this skill.

  if (/(?:^|[;&|]\s*)git\s+(?:-[^\s]+(?:\s+[^\s]+)?\s+)*commit(?:\s|$)/i.test(trimmed)) {

The list governs the public surfaces of the security-issue-fix flow, the <upstream> fix PR before its advisory ships. The hook applies it to every git commit, gh pr create and gh pr comment in every repository the plugin is loaded in. In this repository that blocks ordinary work: \bCVE\b (line 26, case-insensitive) matches fix(generate-cve-json): …, and vulnerability / security advisory appear in many routine commit messages and PR descriptions here and in any adopter's own docs.

Suggested fix: scope it to the configured upstream_repo (from project.md), and preferably to an active fix (a branch or marker the issue-fix skill sets), and let every other repository pass.

This is the single forbidden-term list for every public surface of this skill. (plugins/magpie-security/hooks/public-surface-scrub.ts:128)

This is the single forbidden-term list for every public surface of this skill.

  if (/(?:^|[;&|]\s*)git\s+(?:-[^\s]+(?:\s+[^\s]+)?\s+)*commit(?:\s|$)/i.test(trimmed)) {

Several ordinary shell shapes get a leaking message past the parser (the first four found by codex; each traced in the code):

  • a newline between commands: the detectors accept only start-of-string or ;, &, |, so git status + newline + git commit -m "CVE-… SSRF" is not recognised;
  • an attached message, git commit -m"CVE-… SSRF" (and an attached -F): the extraction requires = or whitespace after the flag;
  • a commit followed by a PR: the git commit branch returns before the gh branches, so git commit -m "neutral" && gh pr create -t "CVE-… SSRF" checks only the commit message, and && does not trigger the whole-command scan;
  • $(…), backticks or a variable in the message, or a piped body (… | git commit -F -): only the literal command text is scanned, and - is skipped;
  • not covered at all: git commit --amend reusing a message, git tag -m, gh pr create --fill, gh pr edit --title/--body, gh pr review --body, gh issue comment on <upstream>, gh api … -f body=.

Suggested fix: split the command into sub-commands on every shell separator (newline included) and evaluate each one; accept attached short-option arguments; when a message comes from command substitution, a variable or stdin, scan the whole command and block when the published text cannot be determined; add the missing subcommands, with a test per shape.

Flagged by both the primary and adversarial reviewers.

Outputs are probabilistic; gates are deterministic (plugins/magpie-security/hooks/public-surface-scrub.ts:263)

Skills produce drafts. Tool calls enforce schemas. Humans or deterministic checks decide whether a draft becomes state.

      } catch {
        // Safe skip on unreadable file; pure regex inspection continues
      }

A message file the hook cannot read is skipped and the command is allowed, and the path is read relative to the session's working directory even after git -C <dir>, where git reads <dir>/<file> (found by codex). So git -C /upstream commit -F msg.txt with an embargoed msg.txt passes whenever the hook resolves the path differently from git.

Suggested fix: resolve -F / --body-file paths against the -C directory, and block (fail closed, naming the file) when a referenced message file cannot be read.

Flagged by codex (adversarial review); cross-checked.

Smaller observations

  • See inline comments on plugins/magpie-security/hooks/public-surface-scrub.ts:28.

This review was drafted by an AI-assisted tool and
confirmed by an Apache Magpie maintainer. After you've
addressed the points above and pushed an update, an Apache Magpie
maintainer — a real person — will take the next look
at the PR. The findings cite the project's review criteria;
if you think one of them is mis-applied, please reply on the
PR and a maintainer will weigh in.

More on how Apache Magpie handles maintainer review:
Contributing guide.

@Kaap10

Kaap10 commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @potiuk. I’ve addressed all findings: the override is now restricted strictly to options.env (removing in-command bypasses and guidance text), and repository scoping resolves upstream_repo/tracker_repo dynamically from project.md evaluated per subcommand.

The parser now splits multi-command pipelines across all shell separators (including newlines), supports attached short flags (-m"...", -Fpath), and enforces deterministic fail-closed behavior on unreadable files, --amend/--fill, and indeterminate dynamic inputs ($VAR, $(...), stdin -).

All 47 unit tests pass cleanly covering each of these review shapes, and plugin validation passes strict static analysis without Node runtime built-in dependencies.

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the round of fixes — the in-command bypass is gone, the tracker / upstream slugs now come from project.md, each sub-command is judged on its own, and unreadable message files and dynamic input now fail closed. Two things still block it: the mod keeps its own copy of the term list instead of using the canonical one, and the gate still fails open in several shapes.

On my earlier threads:

  • Message-file / -C handling — fixed; resolving.
  • In-command bypass, tracker exemption, ordinary shell shapes — answered on their own terms, but the replacements open the gaps in the inline comments (the escape hatch never reaching the hook, cd … &&, git -c, escaped quotes, --repo read from a body, directory-name heuristics).
  • Scope, and the bare advisory — folded into the term-list comment: deriving the list from DEFAULT_TERMS settles both.

On the architecture question in the first inline comment, moving this into tools/agent-guard as a Python guard is worth weighing before more work goes into the TypeScript version: it would import the canonical list directly and run on every harness agent-guard supports.

Smaller, not blocking: in upstream scope this fails closed on the common git commit -m "$(cat <<'EOF' … EOF)" form; that is safe, but the block message could say -F <file> is the expected shape.

single forbidden-term list (plugins/magpie-security/hooks/public-surface-scrub.ts:28)

This is the single forbidden-term list for every public surface of this skill. 5g, Step 7, Step 9 and the Guardrails check against it rather than keeping their own copies.

export const FORBIDDEN_TERM_RULES: ReadonlyArray<{ name: string; pattern: RegExp }> = [

Derive the list from magpie_vcs.publication.DEFAULT_TERMS (generated data file plus a sync check), or move the guard into tools/agent-guard where it can import the canonical list.

Humans or deterministic checks decide whether a draft becomes state. (plugins/magpie-security/hooks/public-surface-scrub.ts:726)

Humans or deterministic checks decide whether a draft becomes state.

const evaluation = await evaluatePublicSurfaceCommand(command, {
        readFile: async (filePath: string) => {

Pass env: $.env, and add a register()-level test with MAGPIE_ALLOW_EMBARGO_TERMS=1.

The boundary never blurs (plugins/magpie-security/hooks/public-surface-scrub.ts:624)

The boundary never blurs, even when the draft looks reliable enough to short-circuit the gate.

} else if (projectConfig.isTrackerCheckout) {
        // Bare git command in the tracker repository itself without upstream context
        continue;

Track cd <dir> across sub-commands; block when the target repository cannot be determined; resolve config in the upstream clone instead of passing everything.

Probabilistic at the input, deterministic at every state change. (plugins/magpie-security/hooks/public-surface-scrub.ts:293)

Probabilistic at the input, deterministic at every state change.

const cwdMatch = trimmed.match(/(?:^|\s)git\s+.*?-C(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/i);

Tokenise argv with a shell-word parser; read -C only from git's global options, case-sensitively; same for -R/--repo.

Smaller observations

  • See inline comments on plugins/magpie-security/hooks/public-surface-scrub.ts:613, plugins/magpie-security/hooks/hooks.json:5.

This review was drafted by an AI-assisted tool and
confirmed by an Apache Magpie maintainer. After you've
addressed the points above and pushed an update, an Apache Magpie
maintainer — a real person — will take the next look
at the PR. The findings cite the project's review criteria;
if you think one of them is mis-applied, please reply on the
PR and a maintainer will weigh in.

More on how Apache Magpie handles maintainer review:
Contributing guide.

Kaap10 added a commit to Kaap10/magpie that referenced this pull request Oct 20, 2026
…closed gates (apache#1676)

- Derive forbidden terms directly from canonical magpie_vcs.publication.DEFAULT_TERMS using tools/dev/sync-forbidden-terms.py, with pre-commit hook to prevent drift.
- Tokenize shell commands with POSIX shell-words parser to handle quotes and escaped quotes.
- Track directory context across chained sub-commands (cd <dir> && git ...).
- Parse git -C only from global options and disambiguate clustered/attached flags.
- Inspect remotes and worktrees to detect contributor fork checkouts as upstream scope.
- Fail closed on indeterminate dynamic input with -F <file> guidance.
- Pass environment from hook event and add register()-level tests for MAGPIE_ALLOW_EMBARGO_TERMS.
- Document mod architecture, tradeoffs, and escape hatch in secure-agent-setup.md.
@github-actions github-actions Bot added family:setup setup-* skills family:tools tools/* family:docs Docs, MISSION.md, READMEs family:ci .github workflows, prek, validators capability:fix Implement code change to resolve an issue capability:resolve Close-out: invalidate, dedupe, CVE allocate, announcement substrate:framework-dev Tool substrate: build / validate / eval the framework itself labels Oct 20, 2026
@Kaap10

Kaap10 commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

will resolve CI shortly!

Kaap10 added a commit to Kaap10/magpie that referenced this pull request Oct 20, 2026
…closed gates (apache#1676)

- Derive forbidden terms directly from canonical magpie_vcs.publication.DEFAULT_TERMS using tools/dev/sync-forbidden-terms.py, with pre-commit hook to prevent drift.
- Tokenize shell commands with POSIX shell-words parser to handle quotes and escaped quotes.
- Track directory context across chained sub-commands (cd <dir> && git ...).
- Parse git -C only from global options and disambiguate clustered/attached flags.
- Inspect remotes and worktrees to detect contributor fork checkouts as upstream scope.
- Fail closed on indeterminate dynamic input with -F <file> guidance.
- Pass environment from hook event and add register()-level tests for MAGPIE_ALLOW_EMBARGO_TERMS.
- Document mod architecture, tradeoffs, and escape hatch in secure-agent-setup.md.
@Kaap10
Kaap10 force-pushed the feat/claude-code-mod-security-scrub-1497 branch from cffec98 to 4a89ad2 Compare October 20, 2026 06:26
@Kaap10

Kaap10 commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @potiuk! Addressed all review items:

  • Derived forbidden terms directly from canonical magpie_vcs.publication.DEFAULT_TERMS with a pre-commit sync check.
  • Added a POSIX shell-words parser, chained cd tracking across subcommands, remote/worktree fork detection, and fail-closed dynamic inputs.
  • Forwarded env into evaluation with register-level tests, documented mod trade-offs in secure-agent-setup.md, and rebased onto main.

Ready for another look!

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this round: the term list is now generated from DEFAULT_TERMS with a prek sync check, argv is tokenised properly, -C / -c and --repo-in-body are fixed, cd is tracked, and the docs section is in. One thing still blocks it: the hook reads the wrong fields of the tool.call event, so in a real session it never sees a command. Behind that, several common shapes still let a message reach the upstream unchecked.

On my earlier points:

  • Canonical term list, tokenising argv, -C / -c / escaped quotes / --repo in a body, directory-name heuristics, heredoc guidance, docs: fixed. I've resolved those threads.
  • Escape hatch through register(): still not reachable (first item below).
  • Block when the target can't be determined: cd is followed now, but a project.md in the session cwd is taken to mean "tracker", which re-opens the upstream case (second item).
  • Moving this into tools/agent-guard: still worth answering before another round. Most of the items below would need solving in either place, and a Python guard importing DEFAULT_TERMS directly would work on every harness.

Blocking: the hook never sees the command (plugins/magpie-security/hooks/public-surface-scrub.ts:1289)

Skills produce drafts. Tool calls enforce schemas. Humans or deterministic checks decide whether a draft becomes state.
— PRINCIPLES.md

const command = e?.input?.command || e?.params?.command;
...
const hookEnv = e?.env ?? (typeof process !== 'undefined' ? process.env : {});
...
return { error: evaluation.reason };

In the mod API the tool's arguments sit at the top level of the tool.call input (e.command for Bash), so e.input.command is undefined and nothing is evaluated. A refusal is { deny: reason }; { error } is not a result the engine knows. The event carries no env, and a hooks module has no Node process, so MAGPIE_ALLOW_EMBARGO_TERMS still cannot reach the hook. The register tests (public-surface-scrub.test.ts:723, :753, :781) build { tool, input: { command }, env } by hand and assert result.error, so they pass against a shape the engine never sends.

Suggested fix: on('tool.call', { tool: 'Bash' }, …), read e.command, return { deny }, and read the bypass with await $.env.get("MAGPIE_ALLOW_EMBARGO_TERMS"). Drive the register tests through claude-code/testing's tool noun, so the call goes through the plugin's hook as in a session.

Major: a project.md in the cwd is treated as the tracker (plugins/magpie-security/hooks/public-surface-scrub.ts:1187)

The boundary never blurs, even when the draft looks reliable enough to short-circuit the gate.
— PRINCIPLES.md

if (projectConfig.isTrackerCheckout && !effectiveDir) {
  continue;
}

isTrackerCheckout is true whenever project.md is found under the cwd (:210). An adopter that commits .apache-magpie-overrides/project.md into its upstream repository has one there, and so does this repository. There, a bare git commit -m "Fix CVE-2026-1234" skips the remote check and passes, although the remote is the upstream.

Suggested fix: drop the shortcut. Always inspect the effective directory's remotes, exempt only on a tracker_repo match, and scan on an upstream_repo (or fork) match.

Major: the PR URL and GH_REPO select the repository too (plugins/magpie-security/hooks/public-surface-scrub.ts:701)

Probabilistic at the input, deterministic at every state change.
— PRINCIPLES.md

if ((token === '-R' || token === '--repo') && i + 1 < tokens.length) {

From a tracker-checkout session, all of these pass and land on the public upstream PR:

  • gh pr comment https://github.com/apache/airflow/pull/1 -b "Fix CVE-2026-1234 SSRF"
  • gh pr edit <same URL> --body "…"
  • GH_REPO=apache/airflow gh pr comment 1 -b "…"

Suggested fix: take the target from a PR/issue URL argument and from a leading GH_REPO=, with a test for each.

Major: gh pr review --body-file is not read (plugins/magpie-security/hooks/public-surface-scrub.ts:888)

Write the text to a file in the session scratch directory or $TMPDIR first, then hand the CLI the path: gh pr create --body-file, gh pr comment --body-file, gh issue comment --body-file, gh pr review --body-file,
— AGENTS.md → Commit and PR conventions

if (token === '-F' && i + 1 < tokens.length) {

Only -F is handled in the review branch. gh pr review 1 -R apache/airflow --comment --body-file evil.md passes without the file being opened.

Suggested fix: handle --body-file / --body-file= as the comment branch does.

Major: gh api coverage is narrower than documented (plugins/magpie-security/hooks/public-surface-scrub.ts:979)

gh api … --input <file> for JSON payloads
— AGENTS.md → Commit and PR conventions

const match = fieldArg.match(/^(?:body|title)=(.*)$/s);

Only an inline body= / title= field is scanned. These all pass against repos/apache/airflow/...:

  • --input body.json, the form AGENTS.md prescribes;
  • -F [email protected], where gh reads the file and the hook sees only @msg.md;
  • gh api graphql -f query='mutation{addComment(input:{…body:"CVE-…"})}'.

Suggested fix: read --input and @file values, and fail closed when they can't be read. Scan or fail closed on a graphql mutation. Otherwise, narrow the claim in secure-agent-setup.md and SKILL.md.

Major: every repository without Magpie config is now scanned (plugins/magpie-security/hooks/public-surface-scrub.ts:1199)

Re-read the diff and check that every change is intentional.
— AGENTS.md → Before submitting

if (!remoteInfo.isUpstream && upstreamRepo) {
  continue;
}

With no project.md, upstreamRepo is undefined and nothing passes. git commit -m "fix(generate-cve-json): tidy" and git commit -m "docs: security advisory template" are blocked in any unrelated repository the plugin is enabled in. That contradicts the new docs line ("Commands targeting unrelated third-party or adopter documentation repositories pass unchecked").

Suggested fix: use the upstream clone path from user.md, so an upstream session is still recognised without a local project.md. Then let repositories whose remotes are known and match neither slug pass, and fail closed only when scope is genuinely unknown.

Smaller observations

  • A single & is not a separator (public-surface-scrub.ts:273). git -C ../airflow status & git -C ../airflow commit -m "Fix CVE-…" passes.
  • reporterNames / privateTexts (:79-107, :1210) are never filled in production, so the reporter-name and verbatim-text checks only run in unit tests. Wire a source for them or drop them.
  • forbidden-terms.json is generated and sync-checked, but nothing reads it.
  • -m 'costs $HOME' in single quotes is blocked as dynamic input, because the $VAR check runs on the raw string rather than the tokens. This is safe, but it is a false positive.
  • If a command writes a message file with a heredoc and then commits with -F on that file, the hook reads the old file before the new text is written.
  • /usr/bin/git, bash -c '…' and (cd … && git commit …) are not recognised. That is fine for a backstop, but then the docs should not say it "cannot be … faked".

This review was drafted by an AI-assisted tool and
confirmed by an Apache Magpie maintainer. After you've
addressed the points above and pushed an update, an Apache Magpie
maintainer — a real person — will take the next look
at the PR. The findings cite the project's review criteria;
if you think one of them is mis-applied, please reply on the
PR and a maintainer will weigh in.

More on how Apache Magpie handles maintainer review:
Contributing guide.

@Kaap10

Kaap10 commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Hi @potiuk,

Thanks for the detailed review! All findings from review have been addressed in the latest commit:

  1. Architecture & Scope:

  2. Hook Registration API & Engine Contract:

    • Registered with tool filter: on('tool.call', { tool: 'Bash' }, ...).
    • Reads top-level e.command directly and returns { deny: ... } when blocked.
    • Reads the bypass variable via await $.env.get('MAGPIE_ALLOW_EMBARGO_TERMS'), passing claude plugin validate --strict.
    • Unit tests now drive registration through tool.call with { command } and assert { deny }.
  3. Remote & Directory Scoping:

    • Dropped the isTrackerCheckout shortcut; remotes are always inspected.
    • Added fallback probing for user.md (upstream_repo / local_upstream) so upstream sessions are recognized even without a local project.md.
    • Commands in unrelated repositories whose remotes match neither tracker nor upstream pass unchecked.
  4. CLI Parser Gaps:

    • Extracts target repository from PR / issue URLs (https://github.com/<owner>/<repo>/pull/<id>) and leading GH_REPO=<owner>/<repo> environment variables.
    • Added --body-file and --body-file= support to gh pr review.
    • Added --input <file>, -F body=@file, and GraphQL mutation detection to gh api.
    • Corrected shell splitting on single & (when not && or >&).
    • Evaluates $VAR dynamic input only outside single quotes, avoiding false positives on strings like -m 'costs $HOME'.
  5. Docs Calibration:

    • Updated docs/setup/secure-agent-setup.md to describe the guard as a zero-token deterministic check against accidental leaks and clarified that inline command environment variables are ignored.

All 60 tests in claude plugin test plugins/magpie-security and claude plugin validate --strict are passing.

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this round: the hook now reads e.command, returns { deny } and reads the bypass through $.env.get; the project.md shortcut is gone; PR URLs, GH_REPO=, gh pr review --body-file, --input / @file, a lone & and single-quoted $VAR are handled; unrelated repositories pass; and the docs and the issue-fix mention are in. I've checked each of those against the code. Four groups of shapes still let embargoed text reach the upstream unchecked: the first two follow on from last round's threads, the other two are new.

Major: an upstream session without project.md now passes everything (plugins/magpie-security/hooks/public-surface-scrub.ts:170)

The boundary never blurs, even when the draft looks reliable enough to short-circuit the gate.
— PRINCIPLES.md

content.match(/\|\s*`?local_upstream`?\s*\|\s*`?([^`|\r\n]+)`?/i) ??
content.match(/^\s*local_upstream:\s*`?([^`\r\n#]+)`?/im);

The user.md fallback looks for upstream_repo or local_upstream, but user.md declares neither: the upstream clone is environment.upstream_clone, and it is a filesystem path, not an owner/repo slug (plugins/magpie-setup/templates/fix-workflow.md:58). So in a session started in the upstream clone, with no project.md there, upstreamRepo stays undefined, inspectDirectoryRemote returns determined: true, isUpstream: false, and the new if (!remoteInfo.isUpstream) continue; at :1315 lets git commit -m "Fix CVE-2026-1234 SSRF" through. Last round this case blocked everything; now it allows everything, which is case (2) of the earlier thread.

Suggested fix: read environment.upstream_clone from user.md, and treat a cwd (or -C / cd target) under that path as upstream. Separately, '~/.config/apache-magpie/user.md' is passed to $.fs.read literally; please confirm $.fs.read expands ~, or resolve it from $HOME. Add a register-level test for the upstream-clone-without-project.md case.

The same missing upstreamRepo also skips every command with an explicit target (:1300, if (parsed.targetRepo && !isTargetUpstreamOrFork) continue;), so gh pr comment 1 -R apache/airflow -b "Fix CVE-…" passes too. A linked worktree gets there as well: resolveProjectConfig probes .git/apache-magpie/project.md, which in a worktree is a file, not the git common dir.

Flagged by both the primary and adversarial reviewers.

Major: a gh api graphql mutation from the tracker checkout is exempt (plugins/magpie-security/hooks/public-surface-scrub.ts:1313)

Probabilistic at the input, deterministic at every state change.
— PRINCIPLES.md

if (remoteInfo.isTracker) {
  continue; // Safe tracker repository
}

The mutation text is now extracted, but a graphql call carries no repos/… path and no -R, so targetRepo is unset and the cwd's remote decides. From the tracker checkout (where the security skills run), gh api graphql -f query='mutation{addComment(input:{subjectId:"<upstream PR node id>",body:"Fix CVE-2026-1234 SSRF"})}' is exempted here and posts to the public upstream PR. The new test at public-surface-scrub.test.ts:803 adds -R apache/airflow, which gh api does not use to target a mutation, so it does not exercise this path.

Suggested fix: a graphql mutation names its target by node id, which the hook cannot resolve, so scan it (or fail closed) regardless of cwd, and drop -R from the test.

Related, in the same branch (:1089): only fields named body, title or query are read, so -f query='mutation($t:String!){addComment(input:{subjectId:"…",body:$t})…}' -f t='Fix SSRF' passes even from the upstream checkout. Scan every -f / -F value of a graphql call.

Flagged by both the primary and adversarial reviewers.

Major: commit and tag messages from sources the parser does not read (plugins/magpie-security/hooks/public-surface-scrub.ts:583)

Add the trailer with git commit --trailer, never by typing it into the message body or the -F <file>:
— AGENTS.md → Commit and PR conventions

if (token === '--amend') {
  // If amending without explicit message or file, flag as dynamic reuse

Each of these puts text into a public commit or tag that the hook never inspects:

  • git commit -m 'Improve validation' --trailer 'References: CVE-2026-1234' — --trailer is not parsed, and it is the form AGENTS.md prescribes for attribution;
  • git commit -C HEAD, --reuse-message=…, --reedit-message=…, --fixup=… / --squash=… — only --amend is treated as message reuse;
  • git tag -am 'Fix CVE-2026-1234' v1 — '-am' fails the hasMsg prefix test at :675, so the tag is not a public-surface command at all.

Suggested fix: scan --trailer values, treat every message-reuse option as dynamic input (fail closed), and recognise clustered short flags containing m / F in the hasMsg gate, with a test for each.

Flagged by codex (adversarial review); cross-checked.

Major: the target directory and remote are resolved wrongly in three shapes (plugins/magpie-security/hooks/public-surface-scrub.ts:1223)

The boundary never blurs, even when the draft looks reliable enough to short-circuit the gate.
— PRINCIPLES.md

if (slug && cleanUrl.includes(slug)) {
  return true;
}

Each of these makes the hook inspect the tracker while the command runs against the upstream:

  • Substring slug match (here): with tracker_repo = example/project and upstream_repo = example/project-core, the upstream remote matches both, and the tracker check at :1312 wins. Any tracker remote also exempts a checkout that has an upstream remote beside it. Match the slug exactly after normalising the URL, and let an upstream match take precedence.
  • cd inside a pipeline (:1274): splitShellCommands drops the separator kind, so cd ../tracker | gh pr comment 1 -b "…" is evaluated as if the cd applied, although each side of a pipe runs in its own subshell. Only apply a cd across &&, ; and newlines.
  • Relative git -C (:1282): parsed.gitCwd || virtualCwd uses the -C value as-is, so cd ../upstream && git -C . commit -m "…" reads the tracker's .git/config. Resolve -C against virtualCwd, and compose repeated -Cs as git does.

Flagged by codex (adversarial review); cross-checked.

Smaller observations

  • See inline comments on plugins/magpie-security/hooks/public-surface-scrub.ts:80, plugins/magpie-security/hooks/public-surface-scrub.ts:1365, plugins/magpie-security/hooks/public-surface-scrub.test.ts:880.

This review was drafted by an AI-assisted tool and
confirmed by an Apache Magpie maintainer. After you've
addressed the points above and pushed an update, an Apache Magpie
maintainer — a real person — will take the next look
at the PR. The findings cite the project's review criteria;
if you think one of them is mis-applied, please reply on the
PR and a maintainer will weigh in.

More on how Apache Magpie handles maintainer review:
Contributing guide.

Kaap10 added a commit to Kaap10/magpie that referenced this pull request Oct 23, 2026
…closed gates (apache#1676)

- Derive forbidden terms directly from canonical magpie_vcs.publication.DEFAULT_TERMS using tools/dev/sync-forbidden-terms.py, with pre-commit hook to prevent drift.
- Tokenize shell commands with POSIX shell-words parser to handle quotes and escaped quotes.
- Track directory context across chained sub-commands (cd <dir> && git ...).
- Parse git -C only from global options and disambiguate clustered/attached flags.
- Inspect remotes and worktrees to detect contributor fork checkouts as upstream scope.
- Fail closed on indeterminate dynamic input with -F <file> guidance.
- Pass environment from hook event and add register()-level tests for MAGPIE_ALLOW_EMBARGO_TERMS.
- Document mod architecture, tradeoffs, and escape hatch in secure-agent-setup.md.
@Kaap10
Kaap10 force-pushed the feat/claude-code-mod-security-scrub-1497 branch from 54adcb6 to f13243a Compare October 23, 2026 09:54
@github-actions github-actions Bot added capability:reassess Re-run historical issues against current code capability:platform Framework / agent substrate skills (install, verify, doctor, override, status, setup bootstrap) labels Oct 23, 2026
@Kaap10

Kaap10 commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

All four Major items and the smaller observations have been resolved and verified.

  • Upstream clone without project.md: Parses environment.upstream_clone filesystem path from user.md, expands ~ via $HOME/USERPROFILE, follows worktree .git gitdir pointers to find project.md, and sets isUpstream = true. Added a register-level test.
  • GraphQL mutations: GraphQL mutations address resources by global Node IDs, so they are never exempt even from the tracker checkout. All -f/-F/--field/--raw-field values are inspected regardless of field name. Dropped -R from the test at line ~803.
  • Commit & tag message sources: Scans --trailer / --trailer= values. Message-reuse options (-C, -c, --reuse-message, --reedit-message, --fixup, --squash) are marked as dynamic input (fail closed). Clustered flags (-am, -sm) are recognized in both commit and tag sections, with hasMsg gate updated.
  • Directory & remote resolution: Exact owner/repo slug extraction (strict ===, no substring .includes()), with upstream remote taking precedence over tracker. cd state only persists across sequential operators (&&, ;, \n), not across pipelines (|). Relative git -C resolves against virtualCwd and composes repeated -C flags.
  • Redirection race & cleanup: Files written via >/>> earlier in a composite command are flagged as dynamic input if later referenced via -F. Removed dead reporterNames/privateTexts code and orphaned forbidden-terms.json. Removed (e?.env ?? {}) fallback in register().

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for round 4: every point from the last review is in, and I checked each against the code — environment.upstream_clone (with ~ expanded) and the worktree gitdir pointer, graphql mutations no longer exempt from the tracker checkout with every -f / -F value scanned, --trailer and the message-reuse options, clustered git tag -am, exact slug matching with upstream precedence, cd only across && / ; / newlines, -C resolved and composed, same-command redirections, and the dead reporterNames / forbidden-terms.json code removed. What remains is narrower: three shapes still let the target be decided by something that does not decide where the command acts, plus a few flag spellings the parser does not read.

Major: a tracker URL in the text being published exempts the command (plugins/magpie-security/hooks/public-surface-scrub.ts:850)

Tracker URLs and #NNN identifiers are public-safe. A URL of
the form https://github.com/<tracker>/issues/NNN, a
#issuecomment-<C> anchor, or a <tracker>#NNN reference may
appear on any surface — public <upstream> PR descriptions,
— AGENTS.md → Confidentiality of the tracker repository

const urlMatch = token.match(/https?:\/\/github\.com\/([a-z0-9_.-]+\/[a-z0-9_.-]+)\/(?:pull|issues)\/\d+/i);
if (urlMatch) {
  result.targetRepo = urlMatch[1].toLowerCase();

The URL search runs over every token, including the -b / --body / --title text, and the match is not anchored. So gh pr comment 1 -b "Fix CVE-2026-1234, see https://github.com/<tracker>/issues/9" sets targetRepo to the tracker, and step 1 at :1452 exempts it, from the upstream checkout. Per AGENTS.md a public fix PR is expected to cite its tracker URL, so this is the ordinary shape of a security-fix PR body, not an edge case.

Suggested fix: take a URL as the target only from the positional PR / issue argument (anchored, ^https://github.com/…/(pull|issues)/\d+$), never from a flag value or body text, and add a test with a tracker URL in the body.

Flagged by codex (adversarial review); cross-checked.

Major: GH_REPO= and {owner}/{repo} decide the target for commands that ignore them (plugins/magpie-security/hooks/public-surface-scrub.ts:515)

Probabilistic at the input, deterministic at every state change.
— PRINCIPLES.md

const ghRepoMatch = envToken.match(/^GH_REPO=([^\s]+)$/i);
if (ghRepoMatch) {
  result.targetRepo = ghRepoMatch[1].toLowerCase();

Three shapes give a command a target it does not act on, and the target then exempts it at :1452 (tracker) or :1496 (unrelated repository):

  • GH_REPO= before git: it is read before the binary is known, so GH_REPO=<tracker> git commit -m "Fix CVE-2026-1234" is exempted in the upstream checkout. Git ignores GH_REPO.
  • GH_REPO= before gh api graphql: the round-4 change makes a mutation non-exempt only when targetRepo is unset (:1504), so GH_REPO=<tracker> gh api graphql -f query='mutation{addComment(...)}' is exempted again. gh api graphql ignores GH_REPO; the node id decides where it posts.
  • gh api repos/{owner}/{repo}/issues/1/comments -f body=… (:1160): the literal {owner}/{repo} becomes targetRepo, fails the upstream match and is skipped as unrelated. gh fills those placeholders from the current repository, which is the upstream.

Suggested fix: honour GH_REPO only for gh subcommands that use it (not git, not gh api); never let a target exempt a graphql mutation; resolve {owner}/{repo} from the cwd remote (or fail closed). A test for each.

Flagged by both the primary and adversarial reviewers.

Major: other spellings of the flags the parser reads (plugins/magpie-security/hooks/public-surface-scrub.ts:868)

The boundary never blurs, even when the draft looks reliable enough to short-circuit the gate.
— PRINCIPLES.md

if (token === '--fill' || token === '--fill-first') {
  result.hasDynamicInput = true;

Each of these is a documented spelling the parser does not recognise, and each publishes text uninspected:

  • gh pr create -f and --fill-verbose (here): only --fill / --fill-first mark the body as dynamic, so the commit messages go into the public PR unread;
  • git commit -CHEAD / -cHEAD (:590): attached short arguments miss the reuse check, which matches only -C, -C=, -c, -c=;
  • gh api … -fbody="…" / -Fbody=@file (:1195): only separate -f / -F tokens and the long --field= forms are read.

Suggested fix: treat any -f, --fill* on gh pr create as dynamic; match attached short forms (/^-[Cc]./, /^-[fF]./) wherever a short flag takes a value, and add a test per spelling.

Flagged by codex (adversarial review); cross-checked.

Smaller observations

  • See inline comments on plugins/magpie-security/hooks/public-surface-scrub.ts:525, plugins/magpie-security/hooks/public-surface-scrub.ts:1482.

This review was drafted by an AI-assisted tool and
confirmed by an Apache Magpie maintainer. After you've
addressed the points above and pushed an update, an Apache Magpie
maintainer — a real person — will take the next look
at the PR. The findings cite the project's review criteria;
if you think one of them is mis-applied, please reply on the
PR and a maintainer will weigh in.

More on how Apache Magpie handles maintainer review:
Contributing guide.

Kaap10 added a commit to Kaap10/magpie that referenced this pull request Oct 23, 2026
…closed gates (apache#1676)

- Derive forbidden terms directly from canonical magpie_vcs.publication.DEFAULT_TERMS using tools/dev/sync-forbidden-terms.py, with pre-commit hook to prevent drift.
- Tokenize shell commands with POSIX shell-words parser to handle quotes and escaped quotes.
- Track directory context across chained sub-commands (cd <dir> && git ...).
- Parse git -C only from global options and disambiguate clustered/attached flags.
- Inspect remotes and worktrees to detect contributor fork checkouts as upstream scope.
- Fail closed on indeterminate dynamic input with -F <file> guidance.
- Pass environment from hook event and add register()-level tests for MAGPIE_ALLOW_EMBARGO_TERMS.
- Document mod architecture, tradeoffs, and escape hatch in secure-agent-setup.md.
@Kaap10
Kaap10 force-pushed the feat/claude-code-mod-security-scrub-1497 branch from 896846d to abc8aa0 Compare October 23, 2026 11:04
@Kaap10

Kaap10 commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for round 5! All points from this review have been addressed and verified with targeted test cases:

  1. Positional-only URL target resolution:

    • Extracted target URLs strictly from positional PR/issue arguments (^https?:\/\/github\.com\/([a-z0-9_.-]+\/[a-z0-9_.-]+)\/(?:pull|issues)\/\d+$), skipping flag values (-b, --body, -t, --title, -F, -R, etc.).
    • A tracker URL in a PR description or comment body no longer sets targetRepo or exempts the upstream command.
  2. GH_REPO= & {owner}/{repo} scoping:

    • Honoured GH_REPO= only for gh subcommands that use it (ignored for git and gh api).
    • GraphQL mutations are never exempted by target repo or GH_REPO=<tracker>, as mutations address resources by global node IDs.
    • Literal {owner}/{repo} placeholders in gh api repos/... are no longer parsed as an explicit target repository and inherit the cwd remote.
  3. Alternative flag spellings:

    • Handled gh pr create -f and --fill-* (including --fill-verbose) as dynamic input.
    • Handled attached message reuse flags (git commit -CHEAD / -cHEAD) via /^-[Cc].+/.
    • Parsed attached field forms on gh api (-fbody=..., -Fbody=@file) via /^-([fF])([a-zA-Z0-9_.-]+)=(.*)$/s.
  4. Command wrappers & scoping:

    • Strips subshell parentheses (...) and unwraps leading command, command -p, and env invocations; fails closed on unrecognized wrappers if git/gh is present.
    • Scoped detectedUpstreamSlug fallback strictly to confirmed upstream checkouts (isWorkingDirUpstreamClone).

All 79 tests in claude plugin test plugins/magpie-security pass (79 pass, 0 fail), and python tools/dev/sync-forbidden-terms.py --check passes with 0 drift.

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five round-5 points are addressed, and each has a test now. A fresh read of the parser (mine, plus a second model whose findings I checked one by one against the code) still finds nine ways a public-surface command gets past it, listed inline; seven of them let embargoed text through.

They share one cause: the hook decides what a shell line does by recognising the shapes it knows, and lets everything else through, so each shape it does not know (/usr/bin/git, --git-dir, a group with && inside, a file written by cp, an option value read as the repository) is an exemption. Six rounds in, I do not think adding cases one at a time will converge. Two changes of direction would:

  1. Invert the default. Allow only the shapes the parser fully understands. When git/gh (by basename) is in command position in a shape it does not understand, or a target or file it cannot resolve before the command runs (a $VAR target, a file another part of the same line writes, a GraphQL payload in a file), block it instead of exempting it.
  2. Say what the hook is. State in the README and in the block message that it is a guard against accidental leaks, not a boundary against a deliberate one, so the cases that cannot be closed statically (a file changed between the check and the use) are documented rather than chased.

The inline comments are the concrete cases the inversion has to cover, and they make good regression tests for it.

A group with an operator inside is split before the parentheses are stripped (plugins/magpie-security/hooks/public-surface-scrub.ts:540)

while (trimmed.startsWith('(') && trimmed.endsWith(')')) {

This only unwraps a group with no operator inside. splitShellSubcommands has already cut (cd ~/upstream && git commit -m "Fix CVE-2026-1234") at &&, so the parts are (cd ~/upstream and git commit -m "…"): the first no longer starts with cd, so the directory change is never tracked, and the commit is judged against the session's cwd. Run from the tracker checkout, it is exempt and passes. The new test only covers ( git commit … ). Please track (/) (and { …; }) in the splitter, or fail closed on a part that opens a group it does not close.

The binary is recognised only as the bare word git / gh (plugins/magpie-security/hooks/public-surface-scrub.ts:606)

const binary = tokens[0];

/usr/bin/git commit -m "Fix CVE-2026-1234" (or ./gh, ~/bin/gh) matches neither binary === 'git' nor the t === 'git' wrapper check below, so it is not a public-surface command at all and passes, even inside the upstream clone. Compare the basename (binary.split('/').pop()), in both places.

Flagged by codex (adversarial review); cross-checked.

--git-dir / --work-tree and GIT_DIR / GIT_WORK_TREE are ignored (plugins/magpie-security/hooks/public-surface-scrub.ts:662)

// Skip other flags

From the tracker checkout, git --git-dir=/up/.git --work-tree=/up commit -m "Fix CVE-2026-1234" and GIT_DIR=/up/.git git commit -m "…" commit to upstream, but the hook inspects the tracker's remote (the env assignment is collected in leadingEnv and dropped; --git-dir=… is skipped here as a flag) and exempts them. The two-word form --git-dir /up/.git is worse: /up/.git is taken as the subcommand, so it is not even a commit. Resolve the repository from these options and variables the way -C is, or fail closed when any of them is present.

Flagged by codex (adversarial review); cross-checked.

An option value is taken as the gh api target repository (plugins/magpie-security/hooks/public-surface-scrub.ts:1267)

if (/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+/.test(token) && !token.startsWith('-')) {

Option values are not skipped here, so in gh api --input payloads/comment.json repos/apache/airflow/issues/1/comments the target becomes payloads/comment.json, which is an "unrelated repository" and is exempted at :1618 before the payload is read. Skip the values of --input, -f/-F, -H, -X, -q, --jq, --template, --cache and -p, and take the endpoint as the first positional.

Flagged by codex (adversarial review); cross-checked.

A GraphQL mutation in an --input file is not recognised as a mutation (plugins/magpie-security/hooks/public-surface-scrub.ts:1356)

for (const t of result.extractedTexts) {

isGraphqlMutation is only read from inline texts. gh api graphql --input payload.json (or -F [email protected]) with an addComment mutation on a public issue leaves it false, so from the tracker checkout step 5 exempts the command and the file is never read. Since a GraphQL call through --input or @file cannot be classified before reading it, treat it as a mutation (never exempt), or read the file at this point and classify it.

Flagged by codex (adversarial review); cross-checked.

Only > redirections count as files written earlier in the same line (plugins/magpie-security/hooks/public-surface-scrub.ts:1551)

// Record newly redirected files from this subcommand

cp private.md msg.md; gh pr comment 1 -R apache/airflow -F msg.md passes: the hook reads the neutral msg.md that is there now, and cp replaces it before gh runs. The same holds for mv, tee, sed -i, curl -o, sh -c '…' and so on, so a list of writers will never be complete. Please fail closed (dynamic input) when a public-surface command reads a file and any earlier part of the same line is not the hook's own known read-only set.

Flagged by codex (adversarial review); cross-checked.

An unresolved target such as -R "$REPO" is exempted before the dynamic-input check (plugins/magpie-security/hooks/public-surface-scrub.ts:1618)

if (parsed.targetRepo && !isTargetUpstreamOrFork) {

With -R "$REPO" the target is the literal $repo, which is not upstream, so this continue runs before hasDynamicInput is looked at, even when REPO=apache/airflow. The same happens for every explicit -R when no upstream_repo is configured. A target that contains $, `, or that cannot be compared because upstream is unknown, should fall through to the scrub rather than be exempted.

Flagged by codex (adversarial review); cross-checked.

Smaller observations

  • See inline comments on plugins/magpie-security/hooks/public-surface-scrub.ts:611, plugins/magpie-security/hooks/public-surface-scrub.ts:1445.

This review was drafted by an AI-assisted tool and
confirmed by an Apache Magpie maintainer. After you've
addressed the points above and pushed an update, an Apache Magpie
maintainer — a real person — will take the next look
at the PR. The findings cite the project's review criteria;
if you think one of them is mis-applied, please reply on the
PR and a maintainer will weigh in.

More on how Apache Magpie handles maintainer review:
Contributing guide.

…ace scrub mod

- Limit escape hatch strictly to options.env set by human, removing in-command bypasses.

- Scope checks to upstream_repo from project.md; evaluate private tracker exemption per subcommand.

- Split multi-command pipelines on shell separators (including newlines) and accept attached short flags.

- Fail closed on unreadable message files and indeterminate dynamic input.

- Resolve file paths relative to git -C directory without node:path.

- Expand test suite to 47 unit tests covering all review shapes.
…closed gates (apache#1676)

- Derive forbidden terms directly from canonical magpie_vcs.publication.DEFAULT_TERMS using tools/dev/sync-forbidden-terms.py, with pre-commit hook to prevent drift.
- Tokenize shell commands with POSIX shell-words parser to handle quotes and escaped quotes.
- Track directory context across chained sub-commands (cd <dir> && git ...).
- Parse git -C only from global options and disambiguate clustered/attached flags.
- Inspect remotes and worktrees to detect contributor fork checkouts as upstream scope.
- Fail closed on indeterminate dynamic input with -F <file> guidance.
- Pass environment from hook event and add register()-level tests for MAGPIE_ALLOW_EMBARGO_TERMS.
- Document mod architecture, tradeoffs, and escape hatch in secure-agent-setup.md.
… and CLI parser

- Correct Claude Code hook registration: register on('tool.call', { tool: 'Bash' }),
  read e.command directly, and return { deny: evaluation.reason } on block.
- Read environment bypass via $.env.get('MAGPIE_ALLOW_EMBARGO_TERMS') to pass strict plugin validation.
- Drop isTrackerCheckout shortcut; inspect git remotes directly and probe fallback user.md for upstream identification.
- Expand CLI subcommand parsing in parseSubcommand:
  * Extract repository targets from PR/issue URLs (https://github.com/<owner>/<repo>/pull/<id>).
  * Extract leading GH_REPO= environment variables.
  * Support --body-file in gh pr review.
  * Inspect gh api --input, -F body=@file, and GraphQL mutations.
- Enhance shell splitting and dynamic tokenization:
  * Split on single & command separator (unless part of >&).
  * Ignore literal $VAR inside single quotes (e.g. -m 'costs $HOME').
- Calibrate documentation in secure-agent-setup.md regarding zero-token deterministic guard.

Generated-by: Antigravity
…rub hook

- Parse environment.upstream_clone from user.md to detect upstream
  clone sessions without project.md; expand ~ via HOME/USERPROFILE
- Follow .git gitdir pointer in linked worktrees to find common dir
- Never exempt gh api graphql mutations from tracker checkout;
  inspect all -f/-F field values in graphql calls, not just body/title
- Extract --trailer values; treat -C, -c, --reuse-message,
  --reedit-message, --fixup, --squash as dynamic input (fail closed)
- Recognise clustered short flags containing m/F in git tag hasMsg gate
- Use exact owner/repo slug matching (extractSlugFromUrl) instead of
  substring .includes(); upstream match takes precedence over tracker
- Preserve separator type in splitShellSubcommands; cd only persists
  across sequential operators (&&, ;, newline), not pipes
- Resolve git -C relative to virtualCwd; compose repeated -C flags
- Track same-command file redirections; flag referenced files written
  earlier in the composite command as dynamic input
- Remove dead reporterNames/privateTexts/shingle code and orphaned
  forbidden-terms.json; sync-forbidden-terms.py generates .ts only
- Remove (e?.env ?? {}) fallback; read bypass exclusively via $.env.get
- Guard process.env access with typeof check for sandbox safety
- Add 9 targeted tests for review round 4 hardening
…apes and address review 6 findings

- Track subshell parenthesis and brace depth in splitShellSubcommands to preserve grouped directory transitions
- Extract binary basename to detect absolute and relative git/gh paths (/usr/bin/git, ~/bin/gh, ./git)
- Limit command wrapper checking to known wrappers and avoid false positives on plain arguments (grep git, which gh)
- Fail closed on repository redirection flags (--git-dir, --work-tree, GIT_DIR, GIT_WORK_TREE) and prevent tracker remote exemption
- Skip gh api option values (--input, -F, -H, etc.) when extracting target repository
- Treat gh api graphql with --input or @file as mutation to prevent tracker checkout exemption
- Strip quotes, trailing slashes, and comments during git remote URL parsing
- Track previous mutating commands on composite shell lines and fail closed when reading files
- Fall through on dynamic target repositories (-R "$REPO") and unconfigured upstream
- Update generateBlockingReason and documentation with accidental-leak guard framing
- Add 15 regression tests covering all Round 6 review findings (94/94 passing)
…ace scrub hook

- Seed virtualCwd from $.session.root() and event cwd in register() hook to reliably evaluate bare git commit in upstream clones without explicit cd.
- Track virtualCwd persistence across subshells vs brace groups: restore cwd after ( ... ) subshell while preserving cwd updates across { ...; }.
- Treat wrapped commands as wrapped if git/gh appears in any subsequent token, skipping wrapper options and arguments (timeout 30 git, nice -n 10 git, sudo -u x git).
- Strip leading slash and API host prefix in gh api endpoints; do not exempt literal endpoints without owner/repo placeholders via tracker cwd.
- Scan all REST field values in gh api (-f, -F, --field, --raw-field) rather than whitelisting body/title.
- Consume heredoc bodies up to delimiter without splitting or quote toggling; fail closed on unclosed quotes when git/gh is present.
- Treat transparent shell control prefixes (then, do, else, elif, !, if, while, until, {, }) transparently, and fail closed on eval or sh/bash/zsh -c invoking git/gh.
- Avoid false positives in unconfigured repositories by falling through to term scan instead of failing closed unconditionally on unconfigured upstream or --git-dir/GIT_DIR.
- Restrict --git-dir and GIT_DIR checking to public-surface commands (commit/tag) and fall through to term scan without cwd remote exemption.
- Fix typo in gh issue comment commandType.
- Add comprehensive regression tests for all review findings (121 passing tests).
@Kaap10
Kaap10 force-pushed the feat/claude-code-mod-security-scrub-1497 branch from b56040a to 44f8901 Compare October 25, 2026 10:54
@Kaap10

Kaap10 commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @potiuk for the thorough round 7 review!

I have addressed every review finding across public-surface-scrub.ts, the pre-commit configuration, documentation, and the test suite:

  1. Bare commands & Session Root CWD: In register(), initialCwd is now seeded from e?.cwd || e?.directory and await $.session.root(). evaluatePublicSurfaceCommand initializes virtualCwd from options.cwd. A bare git commit in an upstream clone is now evaluated against upstream_clone and its .git/config remote. Added register hook tests verifying bare git commit in an upstream clone blocks on embargo terms, as well as testing that commits in private tracker checkouts are permitted.
  2. Subshell ( … ) vs Brace Group { …; }: Subcommands inside ( … ) track subshell depth and restore virtualCwd upon exit, preventing subshell cd from leaking to subsequent commands, while { …; } preserves in-shell directory updates.
  3. Wrapper Arguments: Removed the early break on non-option tokens in KNOWN_WRAPPERS. All subsequent tokens are scanned; commands like timeout 30 git …, nice -n 10 git …, sudo -u dev git …, and timeout 30 gh … fail closed under the dedicated wrapped-command message.
  4. Transparent Shell Control Prefixes & eval/bash -c: Added transparent prefix handling for then, do, else, elif, !, if, while, until, {, and } before extracting the command binary. Added fail-closed checks for eval and sh|bash|zsh -c invocations referencing git or gh.
  5. gh api Endpoint Normalization & Literal Unknown Endpoints: Stripped leading / and API host prefixes (https://api.github.com/). When a literal endpoint lacks {owner}/{repo} placeholders and does not parse into a repo, isLiteralUnknownEndpoint is set so the tracker checkout cwd does not exempt it; it falls through to the term scan. Also hardened against non-repo API routes (/orgs/..., /users/..., /teams/...) so they are not misparsed as repo slugs.
  6. All-Field REST Inspection: Removed the whitelist on body/title; all fields passed via -f, -F, --field, and --raw-field (as well as attached forms like -fnote=...) are extracted and scanned.
  7. Heredoc Bodies & Unclosed Quotes: splitShellSubcommands consumes heredoc bodies up to their matching delimiter (<<, <<-, and <<\EOF) without splitting subcommands or toggling quote state on internal quotes. Fails closed if a command line terminates with an unclosed quote while containing a git/gh binary.
  8. Unconfigured Upstream Repositories: When upstream_repo is unconfigured, explicit targets (-R someone/other) no longer fail closed as dynamic input. Instead, they fall through to the term scan (neutral commands pass, forbidden terms block).
  9. Scope-Restricted --git-dir / GIT_DIR: Non-public surface git commands (status, diff, log) using --git-dir or GIT_DIR are allowed through. For public-surface commands (commit, tag), cwd remote exemption is disabled and commands fall through to the term scan.
  10. Typo Fix: Corrected gh issue comment commandType label from 'gh pr comment' to 'gh issue comment'.
  11. Docs & Pre-commit Sync: Updated .pre-commit-config.yaml file pattern and tools/dev/README.md to reference forbidden-terms.ts.

Rebased on latest upstream/main (dcfe0ad5), resolved the conflict in plugins/magpie-security/skills/issue-fix/SKILL.md, and added regression tests for each finding:

  • claude plugin test plugins/magpie-security: 126 passing tests (0 failures).
  • claude plugin validate --strict plugins/magpie-security: Strict validation passed.
  • python tools/dev/sync-forbidden-terms.py --check: Clean (exit 0).

@Kaap10

Kaap10 commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Proactively hardened a few additional shell and CLI edge cases:

  • cd error guards: virtualCwd now tracks directory transitions across || guards (e.g. cd <upstream> || exit 1), ensuring subsequent commands aren't falsely judged against tracker remote.
  • gh api endpoint normalization: Strips leading /v3/ and /api/v3/ prefixes so /v3/repos/... correctly matches upstream repos instead of treating v3/repos as an external third-party repo.
  • GNU env -C / --chdir: Captures working directory flags passed to env and applies them to command execution context.
  • git merge -m / -F: Added public-surface command classification and embargo term scanning for merge commit messages.
  • Path normalization: Collapses relative . and .. segments in resolvePath deterministically.

All 131 unit tests pass (claude plugin test plugins/magpie-security), and claude plugin validate --strict passes with 0 errors.

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the round-7 fixes. I re-probed each point, and the subshell cd, wrappers, shell keywords and eval, gh api paths, REST fields, heredocs and the false positives are all handled now. Two gaps remain blocking, plus a few concrete bypasses that would each make a good regression test. Details inline.

@Kaap10

Kaap10 commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough Round 8 review, @potiuk. All 10 points have been addressed and verified with regression tests:

Blocking Items

  1. Session-root fallback tracker exemption: Added isSessionRootFallback to EvaluationOptions when e.cwd is omitted and falls back to $.session.root(). Tracker cwd remote exemption is explicitly disabled under fallback mode, falling through to the term scan (and documented in secure-agent-setup.md).
  2. Unknown upstream_repo fail-closed: Guarded remote exemption behind upstreamRepo && !remoteInfo.isUpstream && !parsed.isLiteralUnknownEndpoint, ensuring unconfigured/placeholder upstreams fall through to the term scan.

Edge Cases & Nits

  1. pushd & builtin cd: Added 'builtin' to TRANSPARENT_SHELL_PREFIXES, added pushd to directory tracking, and added pushd/popd/dirs to SAFE_READONLY_COMMANDS.
  2. Missing gh write subcommands: Added parsing and term extraction for gh issue create/edit, gh pr merge, gh release create, and gh pr/issue close.
  3. env -C composition & argument order: Fixed resolvePath argument ordering to resolvePath(result.gitCwd, dir) and initialized gitCwd = result.gitCwd in the git parser.
  4. user.md upstream_clone scanning: resolveProjectConfig now continues probing candidates until upstreamClonePath is found even if project.md declares both repo slugs.
  5. Linked worktrees remote config: inspectDirectoryRemote now probes <commondir>/config where worktree remotes reside.
  6. Tag and branch ref scanning: Positional tag names in git tag and merge source refs in git merge are now captured and scanned.
  7. git commit -am ... --amend: Applied cluster-aware flag check (t.startsWith('-') && !t.startsWith('--') && (t.includes('m') || t.includes('F'))) to avoid false dynamic reuse blocks.
  8. Shared helpers: Consolidated flag parsing across commit, tag, merge, and gh subcommands into shared parseMessageAndFileFlags, parseBodyFlags, etc.

Also synced with main to resolve the SKILL.md conflict and declared no-op Codex hooks for magpie-security to keep check-family-plugins green.

All local tests (141 passed, 0 failed), strict plugin validation, and GitHub Actions CI checks are passing cleanly.

…ks a tracker command

The Bash tool call carries no cwd, so register() always takes the
session-root fallback and the tracker cwd exemption never applies.
A bare tracker command that mentions a CVE id is therefore always
blocked; the block message now says to pass --repo <tracker>, the
docs say the same, and a register() test covers an event with no cwd.

Generated-by: Claude Opus 5
@potiuk
potiuk merged commit 74017f4 into apache:main Oct 26, 2026
12 checks passed

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved and merged after eight rounds. Thanks for sticking with it, Kaap10.
Both round-8 blocking points (the session-root fallback and an unknown upstream_repo) and the other round-8 points check out on the current head.
I ran probe cases against each through claude plugin test.

I pushed one small fixup to your branch (6ddb8e5):

  • plugins/magpie-security/hooks/public-surface-scrub.ts: the Bash tool call carries no cwd, so register() always takes the session-root fallback and the tracker cwd exemption never applies. A bare tracker command that mentions a CVE id is now blocked with a hint to pass --repo <tracker>.
  • plugins/magpie-security/hooks/public-surface-scrub.test.ts: a register() test with no cwd in the event, both blocked and with an explicit --repo.
  • docs/setup/secure-agent-setup.md: one sentence saying the same.

One follow-up, not blocking: the register() tests still hand-build on and $ rather than using claude-code/testing's tool noun, so a drift between the mock event and the real one (like the earlier e.input.command bug) would not be caught.

GitHub has not yet computed whether this branch merges cleanly with its base.


This review was drafted by an AI-assisted tool and
confirmed by an Apache Magpie maintainer. The maintainer
approving this PR has read the findings and signed off. If
something feels off, please reply on the PR and a maintainer
will follow up.

More on how Apache Magpie handles maintainer review:
Contributing guide.

potiuk added a commit that referenced this pull request Oct 27, 2026
#1838)

* feat(hook-manager): register managers that need no install fix (#1825)

Add install strategy 'none' to the hook-manager contract and register managers that write directly to the repository's local hooks directory without requiring global core.hooksPath rewrites: simple-git-hooks, cargo-husky, git-build-hook, and git-code-format-maven-plugin. Document known worktree and configuration caveats.

* chore(hook-manager): fix markdownlint blank line and ruff formatting

* docs(security): Vulnogram review comments cannot be resolved (#1840)

Sync told the operator to "open the CVE record and resolve the review
comment". Vulnogram has no resolve action for comments, in the API or
the UI, so the step could never be done. A comment is now handled once
the body change it asked for is applied, its slug is in the
processed-comments ledger, and the reviewer has a courtesy reply. The
REVIEW -> READY note in apply-and-push.md and the round trip in the
Vulnogram adapter's record.md (which still described the manual
#source paste) now say the same.

Generated-by: Claude Opus 5

* fix(setup): make isolated-setup-verify checks catch false passes (#1808)

* fix(setup): make isolated-setup-verify checks catch false passes

A verify run on a Linux host launched from an IDE extension, with the
project settings only in settings.local.json, reported several checks
as passing that proved nothing:

- Check 1 read only the committed project file and never asked for
  sandbox.filesystem.denyRead. Without "~/" there, bubblewrap reads
  are deny-only and credential files stay readable. Check 1 now
  evaluates the merged project, project-local and user settings,
  requires the home denyRead entry, and names the file each key came
  from.
- Check 7a treated bubblewrap's "No such file or directory" as a pass,
  which a missing file also prints. It now probes common credential
  paths with test -r and stat, never cat, and reports inconclusive
  until the user confirms from a terminal that one exists.
- Check 7b echoed AWS_ACCESS_KEY_ID, which the reference
  sandbox.credentials already unsets, wrapper or not. It now lists
  variable names only, flags credential-shaped ones, and reports an
  IDE CLAUDE_CODE_ENTRYPOINT as a warning, since such a session never
  went through claude-iso. The clean-env wrapper doc says so too.
- Checks 5, 10a and 13c compare against a framework checkout the skill
  never located. A pre-flight now finds it, and reports those
  comparisons as unchecked when there is none.
- Check 10 ran the touch-overlay sub-checks for any signed commit.
  They now apply only to a hardware key; 10c still applies to every
  ssh-format key.
- Sandbox /dev/null placeholders (character special 1,3) looked like
  empty untracked files. The verify and doctor skills now say how to
  recognise them and to take host state from git or the user.

The mirrored prompt in secure-agent-setup.md is kept in sync, and the
step-1-classify eval suite gains four cases for these verdicts.

Generated-by: Claude Code (Claude Opus 5.5)

* fix(setup): address review of isolated-setup-verify checks

Check 10 counted a key as hardware only for an sk- public key or an
OpenPGP-format card. ssh-format signing through gpg-agent uses the
card's aut slot and shows a plain ssh-ed25519 or ssh-rsa key, so a card
gpg --card-status finds now counts whatever gpg.format is.

Check 7a was inconclusive on every correctly secured host, because with
denyRead covering ~/ every path under it looks missing. It now proves
the denial with a sentinel: a file known to exist on the host, such as
the shell rc, that the sandbox cannot see. Inconclusive is left for the
case with no sentinel.

The step-1 output spec gains the inconclusive and unchecked verdicts
the skill now emits, with cases for both and a step-2 case for the
unchecked routing. The rest is review follow-up: managed settings in
check 1's merge, the opt-in question for flagged variable names, the
running claude-code version beside an unchecked check 5, the
credential-shaped wording of the no-print rule, the update prompt's
denial probes, and probe output attributed to stat in the fixtures.

Generated-by: Claude Code (Claude Opus 5.5)

* fix(setup): address second review of isolated-setup-verify

With no framework checkout, the claude-code min_version floor could
not be compared and was reported unchecked, so a marketplace-only
install below the minimum passed. Report that floor as warn instead,
and route the re-run accordingly.

Under zsh on macOS, test -e on a denied sentinel still returns 0, so
the 7a sentinel uses test -r with the /dev/null placeholder exception.
A sentinel that stays readable and is not a placeholder now means
denyRead is not in effect (fail). A one-call test -r ~/.ssh/id_*
misreads as "not readable" with two or more matches (a key plus its
.pub is enough), and zsh errors out on no match, so keys are listed
with find -H (follows a symlinked ~/.ssh), which behaves the same in
bash and zsh.

A plugged-in smart card that does not hold the signing key made the
touch-overlay checks mandatory for software-key users. A card now
counts only when it holds the ssh-format signing key, an OpenPGP
signing-capable key (looked up by user.signingkey, else the committer
email), or the key a gpg-agent ssh remote authenticates with. A new
eval case covers a software signing key with an unrelated card.

7b gains more credential-name patterns, and the docs prompt matches
the skill.

Generated-by: Claude Code (Opus 5.5, Sonnet 5.5)

* fix(container-gateway): refuse driver options on volume create (#1842)

A volume mount entry with a driver configuration is already refused, but
POST /volumes/create (compat and libpod) only checked key spelling before
labelling the volume, and the relay then lets a container mount any
labelled volume by name. Apply the same rule where the volume is made:
only the stock local driver with no options is allowed. The empty
options map and local driver both CLIs send on a plain create still pass.

Generated-by: Claude Opus 5

* feat(security): add deterministic public-surface scrub Claude Code mod (part of #1497) (#1676)

* feat(security): add deterministic public-surface scrub Claude Code mod (part of #1497)

Adds a zero-token Claude Code mod in plugins/magpie-security/hooks/ that intercepts Bash commands (git commit, gh pr create, gh pr comment) and guards public surfaces against accidental security embargo leaks (CVE IDs, vulnerability wording, security fix phrasing, and vulnerability classes).

- Intercepts tool.call on Bash commands, parsing commit messages (-m, -am, -sm, -F) and PR descriptions (-t, -b, -F, --body-file).
- Ingests heredocs and shell pipelines to prevent stdin bypasses.
- Exempts private security tracker repositories (-s suffix convention).
- Provides MAGPIE_ALLOW_EMBARGO_TERMS=1 maintainer escape hatch for legitimate post-coordinated-disclosure maintenance.
- Passes clean behavioral changes through with zero token consumption.
- Includes 29 unit tests covering all matching rules, command syntax, edge cases, and hook middleware integration.

* fix(security): address maintainer review on deterministic public-surface scrub mod

- Limit escape hatch strictly to options.env set by human, removing in-command bypasses.

- Scope checks to upstream_repo from project.md; evaluate private tracker exemption per subcommand.

- Split multi-command pipelines on shell separators (including newlines) and accept attached short flags.

- Fail closed on unreadable message files and indeterminate dynamic input.

- Resolve file paths relative to git -C directory without node:path.

- Expand test suite to 47 unit tests covering all review shapes.

* fix(security): address maintainer review on canonical terms and fail-closed gates (#1676)

- Derive forbidden terms directly from canonical magpie_vcs.publication.DEFAULT_TERMS using tools/dev/sync-forbidden-terms.py, with pre-commit hook to prevent drift.
- Tokenize shell commands with POSIX shell-words parser to handle quotes and escaped quotes.
- Track directory context across chained sub-commands (cd <dir> && git ...).
- Parse git -C only from global options and disambiguate clustered/attached flags.
- Inspect remotes and worktrees to detect contributor fork checkouts as upstream scope.
- Fail closed on indeterminate dynamic input with -F <file> guidance.
- Pass environment from hook event and add register()-level tests for MAGPIE_ALLOW_EMBARGO_TERMS.
- Document mod architecture, tradeoffs, and escape hatch in secure-agent-setup.md.

* docs: document sync-forbidden-terms.py and update issue-fix token count stamp

* fix(security): address maintainer review #3 on Claude Code hook and CLI parser

- Correct Claude Code hook registration: register on('tool.call', { tool: 'Bash' }),
  read e.command directly, and return { deny: evaluation.reason } on block.
- Read environment bypass via $.env.get('MAGPIE_ALLOW_EMBARGO_TERMS') to pass strict plugin validation.
- Drop isTrackerCheckout shortcut; inspect git remotes directly and probe fallback user.md for upstream identification.
- Expand CLI subcommand parsing in parseSubcommand:
  * Extract repository targets from PR/issue URLs (https://github.com/<owner>/<repo>/pull/<id>).
  * Extract leading GH_REPO= environment variables.
  * Support --body-file in gh pr review.
  * Inspect gh api --input, -F body=@file, and GraphQL mutations.
- Enhance shell splitting and dynamic tokenization:
  * Split on single & command separator (unless part of >&).
  * Ignore literal $VAR inside single quotes (e.g. -m 'costs $HOME').
- Calibrate documentation in secure-agent-setup.md regarding zero-token deterministic guard.

Generated-by: Antigravity

* fix(security): address maintainer review round 4 on public-surface scrub hook

- Parse environment.upstream_clone from user.md to detect upstream
  clone sessions without project.md; expand ~ via HOME/USERPROFILE
- Follow .git gitdir pointer in linked worktrees to find common dir
- Never exempt gh api graphql mutations from tracker checkout;
  inspect all -f/-F field values in graphql calls, not just body/title
- Extract --trailer values; treat -C, -c, --reuse-message,
  --reedit-message, --fixup, --squash as dynamic input (fail closed)
- Recognise clustered short flags containing m/F in git tag hasMsg gate
- Use exact owner/repo slug matching (extractSlugFromUrl) instead of
  substring .includes(); upstream match takes precedence over tracker
- Preserve separator type in splitShellSubcommands; cd only persists
  across sequential operators (&&, ;, newline), not pipes
- Resolve git -C relative to virtualCwd; compose repeated -C flags
- Track same-command file redirections; flag referenced files written
  earlier in the composite command as dynamic input
- Remove dead reporterNames/privateTexts/shingle code and orphaned
  forbidden-terms.json; sync-forbidden-terms.py generates .ts only
- Remove (e?.env ?? {}) fallback; read bypass exclusively via $.env.get
- Guard process.env access with typeof check for sandbox safety
- Add 9 targeted tests for review round 4 hardening

* fix(security): address maintainer review round 5 on public-surface scrub hook

* fix(security-scrub): invert default to fail-closed on unresolvable shapes and address review 6 findings

- Track subshell parenthesis and brace depth in splitShellSubcommands to preserve grouped directory transitions
- Extract binary basename to detect absolute and relative git/gh paths (/usr/bin/git, ~/bin/gh, ./git)
- Limit command wrapper checking to known wrappers and avoid false positives on plain arguments (grep git, which gh)
- Fail closed on repository redirection flags (--git-dir, --work-tree, GIT_DIR, GIT_WORK_TREE) and prevent tracker remote exemption
- Skip gh api option values (--input, -F, -H, etc.) when extracting target repository
- Treat gh api graphql with --input or @file as mutation to prevent tracker checkout exemption
- Strip quotes, trailing slashes, and comments during git remote URL parsing
- Track previous mutating commands on composite shell lines and fail closed when reading files
- Fall through on dynamic target repositories (-R "$REPO") and unconfigured upstream
- Update generateBlockingReason and documentation with accidental-leak guard framing
- Add 15 regression tests covering all Round 6 review findings (94/94 passing)

* fix(security-scrub): address maintainer review round 7 on public-surface scrub hook

- Seed virtualCwd from $.session.root() and event cwd in register() hook to reliably evaluate bare git commit in upstream clones without explicit cd.
- Track virtualCwd persistence across subshells vs brace groups: restore cwd after ( ... ) subshell while preserving cwd updates across { ...; }.
- Treat wrapped commands as wrapped if git/gh appears in any subsequent token, skipping wrapper options and arguments (timeout 30 git, nice -n 10 git, sudo -u x git).
- Strip leading slash and API host prefix in gh api endpoints; do not exempt literal endpoints without owner/repo placeholders via tracker cwd.
- Scan all REST field values in gh api (-f, -F, --field, --raw-field) rather than whitelisting body/title.
- Consume heredoc bodies up to delimiter without splitting or quote toggling; fail closed on unclosed quotes when git/gh is present.
- Treat transparent shell control prefixes (then, do, else, elif, !, if, while, until, {, }) transparently, and fail closed on eval or sh/bash/zsh -c invoking git/gh.
- Avoid false positives in unconfigured repositories by falling through to term scan instead of failing closed unconditionally on unconfigured upstream or --git-dir/GIT_DIR.
- Restrict --git-dir and GIT_DIR checking to public-surface commands (commit/tag) and fall through to term scan without cwd remote exemption.
- Fix typo in gh issue comment commandType.
- Add comprehensive regression tests for all review findings (121 passing tests).

* fix(security-scrub): guard against misparsing GitHub API non-repo routes as target repos and handle escaped heredoc delimiters

* test(security-scrub): add mockup tests for session root and private tracker bare commits

* fix(security-scrub): harden public-surface scrub against cd || guards, v3 API routes, env -C, and git merge

* fix(security-scrub): address maintainer review round 8 on public-surface scrub hook

* build(codex): declare no-op codex-hooks for magpie-security

* fix(security-scrub): name --repo <tracker> when the cwd fallback blocks a tracker command

The Bash tool call carries no cwd, so register() always takes the
session-root fallback and the tracker cwd exemption never applies.
A bare tracker command that mentions a CVE id is therefore always
blocked; the block message now says to pass --repo <tracker>, the
docs say the same, and a register() test covers an event with no cwd.

Generated-by: Claude Opus 5

---------

Co-authored-by: Jarek Potiuk <[email protected]>

* chore: bump dev version to 0.9.0.dev202610262031 (#1843)

Move the dev stamp so adopters' `claude plugin update` picks up the
work merged since the previous stamp. The marketplace is served from
`main` and the update check compares version strings, so a frozen
suffix is a silent no-op: an adopter running that command is told they
are already current while sitting behind.

Run from the Actions tab by @potiuk. Generated by
.github/workflows/bump-dev-version.yml with the documented steps: bump
`project.version` in `pyproject.toml`, then
`tools/dev/check-family-plugins.py --fix` and `uv lock` to propagate
to every manifest. The stamp is UTC at minute resolution.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jarek Potiuk <[email protected]>

* chore(deps): bump certifi from 2026.7.22 to 2026.10.18 (#1844)

Bumps [certifi](https://github.com/certifi/python-certifi) from 2026.7.22 to 2026.10.18.
- [Commits](https://github.com/certifi/python-certifi/compare/2026.07.22...2026.10.18)

---
updated-dependencies:
- dependency-name: certifi
  dependency-version: 2026.10.18
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(agent-isolation): hint on container-gateway policy refusals (#1810)

* fix(agent-isolation): hint on container-gateway policy refusals

A tool that drives the docker CLI (e.g. Airflow's breeze) hides the
gateway's 403 body, or gets only "unable to upgrade to h2c, received 403"
from an HTTP-upgrade endpoint, so the agent never saw why it was refused
and routed around the gateway. Add a hint-hook branch for both
signatures and document the refusals (BuildKit, socket bind-mount,
untyped docker inspect) and the expected behaviour: report, don't bypass.

Generated-by: Claude Code (Opus 5.5, Sonnet 5.5)

* test(agent-isolation): parenthesize concatenated parametrize case

CodeQL flagged the implicit string concatenation in the gateway-refusal case list as a possible missing comma. Wrap the two-part literal in parentheses so the concatenation is explicit.

Generated-by: Claude Code (Opus 5.5)

---------

Co-authored-by: Jarek Potiuk <[email protected]>

* feat(tools/discourse): list category topics by latest activity (#1841)

* feat(tools/discourse): list recent category activity

Projects using Discourse need to discover category discussions before
fetching individual topics. Resolve category IDs and catalog paths, then
read explicit activity-ordered lists through the existing GET client.

Filter on bumped_at without substituting the last-message timestamp.
Deduplicate matches and look ahead for an additional match so truncation
reports evidence rather than assuming that another page contains results.
Fail incomplete reads through the existing CLI error channel, and keep
the adapter partial until the remaining archive operations are available.

Generated-by: Codex (GPT-6)

* fix(tools/discourse): bound activity scans and resolve category slugs

Use the requested native descending activity order to stop when an
explicitly non-pinned topic is older than since. Missing pin evidence
must not be replaced by the output default when deciding the cutoff.

Keep category metadata separate from slug matching so an empty or
unusable slug does not block numeric ID reads or unrelated selections.
Resolve bare slugs only at the catalog root, requiring full ancestry
or a numeric ID for subcategories and incomplete catalogs.

Retain evidence-based truncation and the integer-conversion error
channel. Add regressions and clarify the supported CLI behavior.

Generated-by: Codex (GPT-6)

* refactor(tools/discourse): simplify category list handling

Resolve category IDs from native references and restrict catalog lookup to
top-level slugs. Treat continuations as signals while rebuilding every
request with the fixed category and activity order.

Keep whole-page validation and exact truncation, consolidate regressions,
and clarify that pinned category topics count toward the result limit.

Generated-by: Codex (GPT-6)

* fix(tools/discourse): accept rooted category paths

Resolve category paths against the configured forum without duplicating
its base path. Keep full URL boundaries and reject protocol-relative
references before any request.

Clarify unsupported list-view suffixes and cover rooted paths on root
and subfolder installations.

Generated-by: Codex (GPT-6)

* refactor(tools/discourse): parenthesise the tag-name lookup

The expression in _summary already bound as
(name or slug) if dict else tag; the parentheses make that explicit.

Generated-by: Claude Opus 5

---------

Co-authored-by: Jarek Potiuk <[email protected]>

* feat(harness): support Antigravity, remove Gemini CLI (#1845)

* feat(harness): support Antigravity, remove Gemini CLI

Google retired Gemini CLI for individual accounts on 2026-06-18 and
replaced it with the Antigravity CLI (`agy`). Magpie now supports
Antigravity and drops the Gemini CLI harness.

- agent-guard: `--antigravity` adapter for the PreToolUse hook on
  `run_command` (JSON deny decision on stdout); `--gemini` removed.
- Secure setup is per user: tools/agent-isolation/antigravity/ ships
  the settings.json profile (terminal sandbox, proceed-in-sandbox,
  workspace-only file access, allow/ask/deny rules mapped from the
  Claude Code baseline) and the hooks.json guard registration for
  ~/.gemini/config/hooks.json.
- sandbox-lint: `--antigravity` checks the profile, deriving the
  required asks and denies from expected.json; the Gemini profile
  checks and runtime integration tests are removed.
- adversarial-review: `antigravity` backend (stream-json over stdin,
  plan mode, refuses trees that carry agy workspace hooks or MCP
  config); the `gemini` backend is removed and a configured `gemini`
  reviewer gets a pointer to its replacement.
- spec-loop: `antigravity` runner profile replaces `gemini`.
- setup skills, setup-preflight, evals, docs and specs: Antigravity
  replaces Gemini CLI; new docs/adapters/antigravity.md and an
  Antigravity install section; .gemini/, GEMINI.md,
  gemini-extension.json and docs/adapters/gemini.md are removed.

Generated-by: Claude Opus 5

* fix(antigravity): require absolute paths and a confined reviewer mode

- sandbox-lint: an installed Antigravity profile with `~/` path rules
  now fails. Whether agy expands `~` is unverified, so such a rule may
  enforce nothing; only the shipped template may keep the `~/` form.
- adversarial-review: agy takes its permission mode only from the
  user's settings, so the backend reads it from the `init` event and
  discards a review run under any mode other than request-review,
  strict or proceed-in-sandbox.
- docs: record that whether the workspace-hook write deny also binds
  sandboxed shell writes is unverified.

Generated-by: Claude Opus 5

* chore: bump dev version to 0.9.0.dev202610270025 (#1846)

Move the dev stamp so adopters' `claude plugin update` picks up the
work merged since the previous stamp. The marketplace is served from
`main` and the update check compares version strings, so a frozen
suffix is a silent no-op: an adopter running that command is told they
are already current while sitting behind.

Run from the Actions tab by @potiuk. Generated by
.github/workflows/bump-dev-version.yml with the documented steps: bump
`project.version` in `pyproject.toml`, then
`tools/dev/check-family-plugins.py --fix` and `uv lock` to propagate
to every manifest. The stamp is UTC at minute resolution.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jarek Potiuk <[email protected]>

* feat(harness): support Pi; fix OpenCode install docs (#1847)

* WIP: Pi harness (temporary, to be re-committed)

Generated-by: Claude Opus 5

* WIP: Pi policy hardening (plain-word unsandboxed rule, symlinks, directory tools)

Generated-by: Claude Opus 5

* WIP: Pi integrations (guard tests, spec-loop, adversarial-review, setup, docs, evals; OpenCode docs fix)

Generated-by: Claude Opus 5

* build(pi): commit the Pi package lockfile

Pi installs a git package's dependencies with `npm install --omit=dev
--legacy-peer-deps` in its clone, which honours a committed
package-lock.json and does not pass --ignore-scripts. Committing the
lockfile pins the sandbox runtime's whole tree to exact versions and
integrity hashes, so every user gets the reviewed packages instead of
whatever resolves on the day.

The lockfile was generated with the same flags plus
`--package-lock-only --ignore-scripts` and the repository's 7-day
cooldown (`--before`); no package in it has an install script. A
Dependabot npm entry keeps it current with the same cooldown.

Generated-by: Claude Opus 5

* feat(pi): confirm native writes outside the working folder

A native write or edit whose real path, symlinks resolved, falls outside the working folder now needs the user's confirmation, and is refused in a non-interactive run, matching the Antigravity profile's allowNonWorkspaceAccess: false.

Generated-by: Claude Opus 5

* fix(pi): follow dangling links and pin the approved write target

A write through a dangling symlink is now checked at the link's target, the confirmation shows the resolved target with control characters stripped, and a target that changes while the prompt is open is refused. The node test wrapper's skips are explicit returns (CodeQL py/mixed-returns).

Generated-by: Claude Opus 5

* chore(deps): bump the python-deps group across 1 directory with 3 updates (#1850)

Bumps the python-deps group with 3 updates in the / directory: [prek](https://github.com/j178/prek), [zizmor](https://github.com/zizmorcore/zizmor) and [ruff](https://github.com/astral-sh/ruff).


Updates `prek` from 0.5.5 to 0.5.6
- [Release notes](https://github.com/j178/prek/releases)
- [Changelog](https://github.com/j178/prek/blob/main/CHANGELOG.md)
- [Commits](https://github.com/j178/prek/compare/v0.5.5...v0.5.6)

Updates `zizmor` from 1.30.1 to 1.31.1
- [Release notes](https://github.com/zizmorcore/zizmor/releases)
- [Changelog](https://github.com/zizmorcore/zizmor/blob/main/docs/release-notes.md)
- [Commits](https://github.com/zizmorcore/zizmor/compare/v1.30.1...v1.31.1)

Updates `ruff` from 0.17.0 to 0.17.1
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.17.0...0.17.1)

---
updated-dependencies:
- dependency-name: prek
  dependency-version: 0.5.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: python-deps
- dependency-name: zizmor
  dependency-version: 1.31.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: python-deps
- dependency-name: ruff
  dependency-version: 0.17.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-deps
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the github-actions group with 3 updates (#1849)

Bumps the github-actions group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [actions/setup-java](https://github.com/actions/setup-java).


Updates `github/codeql-action/init` from 4.38.3 to 4.39.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee)

Updates `github/codeql-action/analyze` from 4.38.3 to 4.39.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee)

Updates `actions/setup-java` from 6.0.1 to 6.1.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/de7274f081f381c8f8158605e0321c36c376e2e6...b843ebf9b913146b38da2eaa7aeddf76074ae1ad)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: actions/setup-java
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump @anthropic-ai/sandbox-runtime (#1848)

Bumps the pi-package group with 1 update: [@anthropic-ai/sandbox-runtime](https://github.com/anthropics/sandbox-runtime).


Updates `@anthropic-ai/sandbox-runtime` from 0.0.80 to 0.0.81
- [Release notes](https://github.com/anthropics/sandbox-runtime/releases)
- [Commits](https://github.com/anthropics/sandbox-runtime/compare/v0.0.80...v0.0.81)

---
updated-dependencies:
- dependency-name: "@anthropic-ai/sandbox-runtime"
  dependency-version: 0.0.81
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pi-package
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(harness): Goose plugin hook and whole-process sandbox (#1851)

* feat(goose): plugin hook and whole-process sandbox (WIP)

Generated-by: Claude Opus 5

* fix(goose): Goose-only skill manifest, file URLs, read-only config

Generated-by: Claude Opus 5

* fix(goose): no URL exemption on writes; on-disk letter case for path rules

Generated-by: Claude Opus 5

* fix(sandbox): add resolved paths to the sandbox rules (macOS /tmp)

Generated-by: Claude Opus 5

* fix(sandbox): resolve only macOS system links in allow lists

Generated-by: Claude Opus 5

* feat(goose): integrations, docs, evals; ignore non-Goose hook payloads

Generated-by: Claude Opus 5

* fix(goose): fail closed on unexpected hook payloads; reviewer runs outside the tree

Generated-by: Claude Opus 5

* fix(hook-manager): address review on #1838

* fix(adversarial-review): keep the end of a reviewer's raw output and its stderr (#1854)

The report kept the first 2000 characters of a reviewer's output. A
streamed reply ends with the event that says how the run finished, so
the part that explains a failure was the part cut off, and stderr from
a reviewer that exited 0 without an answer was not kept at all.

Keep the first 500 and the last 1500 characters, mark the cut, and add
stderr to a malformed reply.

Generated-by: Claude Opus 5

* fix(adversarial-review): say why an Antigravity review came back empty (#1852)

Headless agy cannot ask for approval, and the first tool it denies ends
the turn with an empty response, still marked SUCCESS. A reviewer that
reached for a shell command lost its whole review, and the report said
only "malformed output".

Ask agy to stick to file reads and searches, name the denied tool when
it ends without an answer, and correct the docs that called the denial
a soft one.

Generated-by: Claude Opus 5

* fix(hook-manager): address review 2 on #1838

* fix(code-review): give inline comments the finding's text, not just its rule (#1853)

render built each inline comment from `comment`, `explanation` or
`rule_id`. Findings in the shape review-flow.md documents carry
`quoted_rule` and `suggestion` instead, so every inline comment was
posted as the bare rule label and told the contributor nothing.

An inline comment now carries the finding as the review body does,
minus the excerpt it sits on, and a folded finding falls back to its
suggestion before its rule.

Generated-by: Claude Opus 5

* Restructure the threat model project-wide and add SECURITY.md (#1859)

Rebuild docs/security/threat-model.md on the Alpha-Omega threat-model
rubric (sections 1.1-1.19) and widen it from the security skill family
to the whole project: enforcement layer, ingest adapters, distribution
and configuration. The previous model is carried over unchanged as
Appendix B, with a back-map from its sections to the new claims.

Add SECURITY.md (report privately to [email protected], link the
threat model) and point AGENTS.md and the project manifest at it.

Generated-by: Claude Opus 5

* security: one reply, one thread -- never combine replies across threads (#1858)

Make it a golden rule that every drafted email answers exactly one
inbound thread. Combining the responses to several threads into one
message -- the consolidated receipt in security-issue-import, or one
foundation-security note listing several dashboard cases -- confuses
reporters, who cannot tell which report the reply answers, and the
security team, who track each case by its thread and Message-ID.

- AGENTS.md and tools/gmail/threading.md carry the rule; issue-import
  and issue-sync add it as a golden rule; bulk sync, bulk invalidate,
  deduplicate and cve-allocate point at it.
- issue-import drops the cross-thread consolidated receipt; one thread
  split into several trackers still gets one reply on that thread.
- issue-sync's dashboard drafts one asf-security note per case.
- security-management wording follows; evals flip the same-reporter
  case to separate receipts and add a one-thread-split case.

Generated-by: Claude Opus 5

* agent-guard: see through more wrappers; read MAGPIE_GUARD_OFF from the hook env (#1860)

The guards now unwrap exec, nohup and time as well as env and command,
and check a shell's -c string (bash/sh/zsh) as a command line of its own,
one level deep. A command line whose quoting cannot be parsed is refused
when it names git or gh, instead of being allowed unchecked.

MAGPIE_GUARD_OFF is read from the hook's own environment only, so the
switch is set by whoever launches the agent rather than written into a
command. Per-guard MAGPIE_ALLOW_* overrides work as before, inline or
from the hook environment.

Generated-by: Claude Opus 5

* container-gateway: refuse writable binds over agent and git config (#1861)

* container-gateway: refuse writable binds over agent and git config

A container may no longer get a writable bind whose source is, contains
or sits under the paths the agent harness and git read their
configuration from: .claude/settings*.json, .claude/hooks,
.claude/skills, .claude/commands, .claude/agents, .mcp.json, .git, the common git directory's hooks and config, a
linked worktree's git directory, the run directory and the personal
config layer. The same binds read-only are still allowed, as is a
writable bind of a subdirectory that covers none of them. Only an exact
`ro` makes a bind read-only, and podman's `U` beside it counts as
writable.

Generated-by: Claude Opus 5

* container-gateway: refuse overlay mount options

Generated-by: Claude Opus 5

* container-gateway: protect agent config inside nested worktrees

Generated-by: Claude Opus 5

* Tighten adapter defaults: TLS, collaborator rollups, HTTPS-only bridge (#1862)

* Tighten adapter defaults: TLS, collaborator rollups, HTTPS-only bridge

- imap: connect with a verified TLS context (ssl.create_default_context()).
- github-rollup and vetted-ops: a status-rollup comment is read only when
  its author is the authenticated gh user or a repository collaborator.
- jira bridge.groovy: refuse non-HTTPS base URLs and do not follow
  redirects.
- sourcehut: comment bodies go through --body-file only; graphql
  mutations need --allow-mutation; the service must be a bare subdomain.
- jira-patch docs: the reviewed attachment is pinned by id and sha256
  before it is applied.

Generated-by: Claude Opus 5

* Stop on an unverifiable rollup author; end GraphQL comments at CR

Generated-by: Claude Opus 5

* Security workflow: stricter relay detection and advisory URL checks (#1863)

* Security workflow: stricter relay detection and advisory URL checks

- Forwarder relay detection needs both the adapter's sender pattern and
  its preamble to match; a single match is surfaced to the user as "not
  a relay". Relay-derived reporter credit stays a proposal until a human
  confirms it.
- New `security-management issue-sync advisory-archive` check: a Public
  advisory URL is accepted only for a thread on the advisory list whose
  root subject is `<CVE-ID>: <description>`, under the configured
  archive URL, and always with confirmation.
- PonyMail: an empty result for a private list reads as "unknown" unless
  the list is visible to the session.
- Docs: egress-gateway, cve-org, forgejo and mailman3 curl notes, and
  macOS egress in secure-agent-internals.

Generated-by: Claude Opus 5

* advisory-archive: require an authorized sender and a configured archive

Generated-by: Claude Opus 5

* advisory-archive: parse the sender as a mail header

Generated-by: Claude Opus 5

* issue-sync: say the advisory sender is unverified when asking to confirm

Generated-by: Claude Opus 5

* agent-guard: a continued heredoc line is not stripped (#1864)

Generated-by: Claude Opus 5

* setup: import the pre-flight checker only from outside the working tree (#1866)

* setup: import the pre-flight checker only from outside the working tree

Pre-flight now runs `python3 -P -m setup_preflight` with PYTHONPATH set
to the git directory's apache-magpie/ (or .svn/apache-magpie/) only.
`-P` keeps the working tree off the import path, and no in-tree
directory is on it, so a repository cannot ship the module pre-flight
imports. `config` installs the checker into that checker_dir (new
`checker_home()` in layers.py); the regenerated block is in every skill.

The checker refuses a Python older than 3.11 before importing anything
(exit 3, which the block reads as "did not run"), and the test session
refuses to start on one.

For a git-tag install, `upgrade`, `verify` and `install` also compare the
committed lock's commit anchor with the fetched commit and with the tag
resolved upstream. A tag that moved stops the upgrade; re-pinning to it
needs `bump-committed` and an explicit confirmation.

Generated-by: Claude Opus 5

* setup: trust .svn only in a real working copy; check an existing snapshot's commit

Generated-by: Claude Opus 5

* agent-guard: strip heredoc bodies only where bash treats them as data (#1867)

* agent-guard: open a heredoc only on an unquoted, uncommented <<

When a command line cannot be tokenised, the guard strips heredoc bodies
before parsing again. It found the `<<WORD` operators with a regex over
the raw line, so a `<<EOF` inside quotes or a `#` comment counted as a
heredoc, and the lines after it were dropped as its body -- while bash
runs them as commands:

    cat /dev/null # <<EOF it's
    git push --force
    EOF

The operators are now found by a scan that tracks quotes (including
`$'...'` and quotes left open on an earlier line) and stops at a
comment. A body naming git / gh under an unquoted delimiter is also no
longer stripped when it holds a `$(...)` or backtick substitution, which
bash expands even in a heredoc fed to cat or tee.

Generated-by: Claude Opus 5

* agent-guard: read heredoc delimiters and shifts as bash does

Two more places where the heredoc stripper and bash disagreed:

- `<<` inside arithmetic or a parameter expansion (`$[1<<x]`,
  `${a[1<<x]}`) is a shift, not a heredoc, yet its "body" was stripped.
  Such a line now leaves the command unstripped.
- The delimiter was matched as `[A-Za-z_][\w.-]*` with optional
  surrounding quotes, so `E-O:F`, `'E'OF` or `E"O"F` closed the body on
  a different line than bash does. The delimiter is now the whole word
  up to the next metacharacter, after quote removal, and quoted when any
  quote or backslash appears in it; a word with `$` or a backtick is not
  stripped.

A body naming git / gh is also stripped only when the introducing line
is a bare `cat` / `tee` with plain file arguments and `>` / `>>`
redirections.

Generated-by: Claude Opus 5

* agent-guard: strip a heredoc body naming git / gh only from a bare cat

Rather than chasing each shape in which a heredoc fed to cat / tee is not
really data, strip a body naming git or gh only in the narrowest safe
shape, and keep the command whole otherwise:

- the introducing line starts a command (the line before does not end in
  `\`, `|`, `&&` or `||`);
- its first word is exactly `cat`, `tee`, `/bin/cat` or `/usr/bin/cat`;
- the command has no `alias`, no `cat` / `tee` function, and no `$((`,
  `((`, `$[` or `${` anywhere.

Generated-by: Claude Opus 5

* agent-guard: keep a heredoc whole when its output may be run, and see into eval

A body naming git / gh fed to a bare cat was still stripped when the cat
output itself was run: `(cat <<EOF ... ) | bash`, `{ ...; } | bash`.
Instead of matching each wrapper, a body naming git or gh is now
stripped only when the command, with its heredoc bodies removed, holds
no `$`, backtick, parenthesis or brace, no `alias`, and no `eval`,
`source` or `.` in command position. This subsumes the earlier
function and arithmetic checks.

`eval` hid what it ran from every guard, heredoc or not. Its arguments,
joined as bash joins them, are now split and checked like a shell -c
string, one level deep.

Generated-by: Claude Opus 5

* agent-guard: every heredoc a sink before stripping; harden eval unwrapping

A heredoc fed to something other than cat / tee had its body stripped
too, so `bash <<A` with `cat(){ bash; }` in its body redefined cat out
of sight before a later stripped `cat <<EOF` body naming git ran. When
any stripped body names git or gh, every heredoc in the command must now
be a qualifying sink, or the command is kept whole. The eval / source /
. check also matches them behind `builtin`, `command` or inline
assignments (`X=1 eval`).

eval unwrapping drops one leading `--` (`eval -- 'git push'`) and
treats `builtin eval` like `eval`; an eval whose first argument is any
other option is marked unparsed and refused.

Generated-by: Claude Opus 5

* agent-guard: see eval through prefix chains; keep heredocs next to eval-like words

eval unwrapping now strips any chain of `builtin`, `command`, `--` and
`-p` in front of eval (`builtin command -- eval ...`). Any other option
in that chain, or after eval, marks the segment unparsed so it is
refused.

A stripped heredoc body naming git or gh is now kept whenever `eval`,
`source`, `.`, `hash` or `enable` appears as a word anywhere in the rest
of the command after quote and backslash removal (`xargs e'v'al < s`,
`env . s`, `hash -p /bin/bash cat`), not only in command position.

The README notes the limit of a command-string guard: a cat heredoc
that writes a script a later command runs is out of its reach, since the
agent could write the same file with its file tools.

Generated-by: Claude Opus 5

* skill-evals(setup): bring seven drifted steps in line with the skill (#1868)

Twelve setup cases failed on main because the fixtures or output specs
no longer matched what the skill says, not because the skill was wrong:

- step-delegate-propose: name skills by their `name`, not their path.
- config-local-custom-parser: the parser is confirmed on the user's
  terminal via trust-parser, with the upstream offer in the same reply;
  cases 1 and 4 now follow that flow, and "this run" is defined.
- step-m3-baseline-pick: security brings magpie-security-tools.
- step-m4-install-gates: the hand-off is /magpie-setup:setup adopt; a
  bare marketplace add has no scope.
- step-override-bypass: the reason names the overrides as skipped or
  applied; "framework defaults" counts as skipped.
- uninstall-default-set: enabledPlugins is edited, never preserved.
- upgrade-adoption-split: updates_plugins means a version changed.

Generated-by: Claude Opus 5

* agent-guard: fail closed on untokenisable lines that can spell git or gh (#1865)

* agent-guard: see git / gh through quoting on an untokenisable line

A command line shlex cannot tokenise is refused when it names git or gh
as a word, and allowed otherwise. The check ran a plain word regex over
the raw text, so quoting inside the word hid it, although bash removes
the quotes and runs the command:

    g'i't push --force # it's

Quotes and backslashes are now removed before the check, at all three
places it runs: the untokenisable line, a stripped heredoc body, and a
nested shell -c string.

Generated-by: Claude Opus 5

* agent-guard: fail closed on an untokenisable line with an expansion

Removing quotes and backslashes still missed words bash builds from an
expansion that may be empty or a line continuation: `g$()it`,
`g${x:-}it`, `g``it`, `g$@it`, `$CMD`, `g\<newline>it`. An untokenisable
line holding `$`, a backtick or a backslash-newline is now refused
outright, and the quote removal also drops `$` before a quote
(`$'g'h`) and backslash-newline.

Generated-by: Claude Opus 5

* agent-guard: fail closed on globs and braces in an untokenisable line

`/usr/bin/gi?`, `/usr/bin/g*t`, `/usr/bin/g[i]t` and `/usr/bin/g{i,}t`
all resolve to git. An untokenisable line holding `*`, `?`, `[` or `{`
is now refused alongside the expansions and line continuations.

Generated-by: Claude Opus 5

* setup-preflight: restamp the isolated-setup fingerprint after rebase

Generated-by: Claude Opus 5

* adversarial-review: keep agy from handing the review to a subagent (#1871)

Asked to list or search a tree, headless agy starts a research subagent
and its turn ends at once, SUCCESS, with "waiting for the subagent" as
the answer or none at all. Every Antigravity review on a real diff came
back empty this way. Told only "file reads", it instead reads
directories file by file and runs out of budget.

The prompt note now names the tools that work (list_dir, find_by_name,
grep_search, view_file / read_file) and forbids subagents, and the
output check reports a subagent handoff by name instead of "no response
text".

Generated-by: Claude Opus 5

* skill-evals(security-issue-sync): follow the dashboard steps as they now read (#1872)

step-d0: a proceeding mode asks for a session only after the bridge
reports one missing. step-d4: since one reply, one thread, each case
with an asf-security finding gets its own note.

Generated-by: Claude Opus 5

* setup: import the pre-flight checker from a per-user directory, never from the project (#1870)

#1866 moved the checker out of the working tree into the project's
VCS directory (<git-common-dir>/apache-magpie/ or .svn/apache-magpie/),
on the reasoning that no commit can carry it. That holds for a git commit,
but not for a Subversion checkout, which versions a .git/ like any other
directory, nor for a tarball, which can carry a .git/ or a .svn/ with its
wc.db. `git rev-parse` and layers.py accept such a directory as real, so
opening that tree put its planted setup_preflight/ on the import path of
every skill's pre-flight.

The checker now lives in one per-user directory,
~/.cache/apache-magpie/preflight/, and the block runs
PYTHONPATH="$HOME/.cache/apache-magpie/preflight" python3 -P -m setup_preflight.
Nothing inside the opened project is ever on the import path, and a
project without version control now gets the checker too. The personal
configuration layer is unchanged. `upgrade` moves a copy an earlier
version left in the project's .git/ or .svn/ as well.

Generated-by: Claude Opus 5

* agent-guard: keep a command whole when any line is continued (#1869)

Heredoc stripping refused a continued line only when that line opened a
heredoc (#1864). Any other line ending in a backslash was read on its
own, but bash joins it with the next line: a `#` at the start of that
line then sits mid-word instead of opening a comment.

That let a guarded command through. In

    echo x\
    #'
    cat <<EOF
    '
    git push --force
    EOF

bash reads the word `x#'`, whose quote swallows `cat <<EOF` and closes on
the `'` line, and then runs `git push --force`. The guard took `#'` for
a comment and stripped the `git` line as the body of a `cat` heredoc.

Heredoc stripping now keeps the command whole whenever any line outside
a heredoc body ends in a backslash, so such a line is refused as
unparsable when it names git or gh. A backslash inside a body is still
data.

Generated-by: Claude Opus 5

* agent-guard: split a command line into commands the way bash does (#1873)

* agent-guard: split a command line into commands the way bash does

split_segments tokenised with shlex.split, which reads a newline as plain
whitespace and only splits on an operator surrounded by spaces. So
`echo hi` + newline + `git push`, or `echo hi;git push`, came out as one
`echo` segment and no guard saw the git command. Subshells, groups,
reserved words and command substitutions hid commands the same way.

A small lexer now reads the line with bash's rules: a newline or any
control operator ends a command, spaced or not; redirections (2>&1, &>,
>|) stay words; `#` comments and quoted text are not commands; a leading
reserved word is skipped; subshells, command and process substitutions
(also inside double quotes, arithmetic and ${...}) are read as commands
of their own. A heredoc body stays data on its command (and in its raw
text for the trailer scan), except that its substitutions are read when
the delimiter is unquoted, and a body naming git / gh that is fed to
anything but a bare cat / tee is read as commands.

Generated-by: Claude Opus 5

* agent-guard: close four gaps in the command-line lexer

- A `case` word starting a command inside `$( … )` or a subshell makes
  the line untokenisable: its pattern's `)` would close the construct
  early, and inside double quotes that hid the rest of the line.
- A redirection keeps `&` / `|` only after an unquoted, unescaped `>` /
  `<`; `echo '>'&git …` splits as bash splits it.
- A standalone `{` or `}` ends the command before it, so
  `function f { git …; }` reaches the guards.
- `$'…'` strings decode bash's escapes (`\xHH`, octal, `\uHHHH`, …);
  `\c` makes the line untokenisable.

Generated-by: Claude Opus 5

* agent-guard: keep braces, NULs and `case` from moving command boundaries

- `{` opens a group only in command position (or after `function name`)
  and `}` closes one only right after a control operator or newline;
  anywhere else a brace is an ordinary word, so `git commit -m x } -m …`
  keeps its second message on the commit.
- A `$'…'` escape that decodes to NUL makes the line untokenisable: bash
  truncates the word there.
- Any unquoted `case` word inside a substitution or subshell makes the
  line untokenisable, whatever precedes it (`time`, `time -p`, …).
- #1865's untokenisable-spelling tests end in an unbalanced quote instead
  of a `# it's` comment, which the lexer now reads as a comment.

Generated-by: Claude Opus 5

* agent-guard: a group behind time or coproc is a group

`time { git push; }`, `time -p { … }` and `coproc [NAME] { … }` open a
brace group, since `time` (with `-p` / `--`) and `coproc` keep command
position. The lexer read the `{` there as a plain word, so the commands
inside the group were never checked.

Generated-by: Claude Opus 5

* agent-guard: check the command a literal name and leading redirections leave (#1874)

* agent-guard: refuse a command whose name is an expansion or a glob

A command line that tokenises but spells its command name through an
expansion or a glob (`$CMD push`, `"$GIT" push`, `$(echo git) push`,
`/usr/bin/gi? push`, `/usr/bin/g{i,}t push`) ran unchecked: the guards
cannot see which command it resolves to. #1865 refused such spellings
only on a line that could not be tokenised. Now a command whose name,
after wrappers are unwrapped, holds `$`, a backtick or a glob or brace
character is refused (`agent-guard[expansion]`); `[` and `[[` are
exempt, and the vetted-ops and excluded-script spelling guards keep
their more specific message.

Generated-by: Claude Opus 5

* agent-guard: accept only a literal command name; skip leading redirections

The command-name check refused a denylist of expansion and glob
characters, which missed zsh's `=git` (expands to git's path) and
`g(i)t` glob group. A command name must now be literal: letters, digits,
`_ . / + : @ % , -` and an optional leading `~/` (`[` and `[[` aside).
A `(` glued to a word other than `name()` or `arr=(…)` makes the line
untokenisable, since bash rejects it and zsh reads it as a glob.

Redirections written before the command name (`>/dev/null git …`,
`2>&1 git …`, `nohup >log env git …`) were read as the command name, so
the git command after them was never checked. They are now skipped like
leading `NAME=value` assignments, also between wrappers, so the command
after them is the one checked; `exec 3>&1` stays allowed.

Generated-by: Claude Opus 5

* fix(security-issue-deduplicate): keep the CVE both trackers share (#1857)

When the kept and the dropped tracker carry the same CVE ID, classify
returned that ID as `retract`, so the proposal offered item 7 as
"retract CVE-X, merged into CVE-X": a retraction of the record the
kept tracker still needs.

classify now proposes a retraction only when the two IDs differ, and
apply-plan refuses item 7 when the ID to retract is the kept CVE. The
skill and the lifecycle spec say the same: a retraction needs two
distinct IDs.


Generated-by: Claude Code (Opus 5.5)

Signed-off-by: Davide Polato <[email protected]>

* setup: trust a VCS directory as the personal layer only when it is unambiguous and ours (#1875)

An unadopted project keeps personal configuration in its VCS directory,
`<git-common-dir>/apache-magpie/` or `<.svn>/apache-magpie/`. That
directory is chosen from what the opened tree carries, and Subversion
versions a `.git/` like any other directory while git versions a
`.svn/`, so a crafted tree could hide configuration where nobody looks.

Every copy of `unadopted_home` now uses the VCS directory only when the
current user owns it (git's own `safe.directory` rule) and it is the
only one at the root. With both a `.git` and a Subversion working copy
there, or a VCS directory another user owns, the home is the in-tree
`.apache-magpie-local/`, in plain sight. This replaces the old rule that
git wins when both are present.

agent-guard runs its own git commands with `core.fsmonitor=false` and
an empty `core.hooksPath`, so a crafted `.git/config` names no program
the hook runs. The commands it runs today (`rev-parse`, `merge-base`,
`rev-list`) do not refresh the index, so this is defence in depth; the
pre-flight already reads `.git` in pure Python and never spawns git in
the project.

Generated-by: Claude Opus 5

* security-cve-allocate: one CVE-allocated draft per reporter, each on its own thread (#1876)

A tracker that merged several independent reporters (security-issue-
deduplicate keeps each one's thread line) only drafted the "CVE
allocated" email to the primary thread, so the other reporters were
never told. Under the one-reply-one-thread rule each of them is owed a
draft of their own, never one email to all.

- draft-plan lists the further reporters' threads in `reporter_threads`
  when it plans the primary; each is planned by its own run with
  `thread_id` set and that reporter's data. A relay line is no further
  reporter; a thread the field does not record is blocked.
- proposal, the rollup entry and the recap take one plan per reporter
  (`drafts`, `notifications`): item 5 creates one draft per reporter
  thread, and the rollup carries one notification line per reporter,
  named by its thread.
- tracker-updates.md, reporter-draft.md and SKILL.md say so; a new eval
  case covers the second reporter's draft, which names no other
  reporter.

Generated-by: Claude Opus 5

* test(security): use claude-code/testing tool noun in public-surface-scrub tests (#1855) (#1856)

* test(security): use claude-code/testing tool noun in public-surface-scrub tests (#1855)

* fix(code-review): give inline comments the finding's text, not just its rule (#1853)

render built each inline comment from `comment`, `explanation` or
`rule_id`. Findings in the shape review-flow.md documents carry
`quoted_rule` and `suggestion` instead, so every inline comment was
posted as the bare rule label and told the contributor nothing.

An inline comment now carries the finding as the review body does,
minus the excerpt it sits on, and a folded finding falls back to its
suggestion before its rule.

Generated-by: Claude Opus 5

* Restructure the threat model project-wide and add SECURITY.md (#1859)

Rebuild docs/security/threat-model.md on the Alpha-Omega threat-model
rubric (sections 1.1-1.19) and widen it from the security skill family
to the whole project: enforcement layer, ingest adapters, distribution
and configuration. The previous model is carried over unchanged as
Appendix B, with a back-map from its sections to the new claims.

Add SECURITY.md (report privately to [email protected], link the
threat model) and point AGENTS.md and the project manifest at it.

Generated-by: Claude Opus 5

* security: one reply, one thread -- never combine replies across threads (#1858)

Make it a golden rule that every drafted email answers exactly one
inbound thread. Combining the responses to several threads into one
message -- the consolidated receipt in security-issue-import, or one
foundation-security note listing several dashboard cases -- confuses
reporters, who cannot tell which report the reply answers, and the
security team, who track each case by its thread and Message-ID.

- AGENTS.md and tools/gmail/threading.md carry the rule; issue-import
  and issue-sync add it as a golden rule; bulk sync, bulk invalidate,
  deduplicate and cve-allocate point at it.
- issue-import drops the cross-thread consolidated receipt; one thread
  split into several trackers still gets one reply on that thread.
- issue-sync's dashboard drafts one asf-security note per case.
- security-management wording follows; evals flip the same-reporter
  case to separate receipts and add a one-thread-split case.

Generated-by: Claude Opus 5

* agent-guard: see through more wrappers; read MAGPIE_GUARD_OFF from the hook env (#1860)

The guards now unwrap exec, nohup and time as well as env and command,
and check a shell's -c string (bash/sh/zsh) as a command line of its own,
one level deep. A command line whose quoting cannot be parsed is refused
when it names git or gh, instead of being allowed unchecked.

MAGPIE_GUARD_OFF is read from the hook's own environment only, so the
switch is set by whoever launches the agent rather than written into a
command. Per-guard MAGPIE_ALLOW_* overrides work as before, inline or
from the hook environment.

Generated-by: Claude Opus 5

* container-gateway: refuse writable binds over agent and git config (#1861)

* container-gateway: refuse writable binds over agent and git config

A container may no longer get a writable bind whose source is, contains
or sits under the paths the agent harness and git read their
configuration from: .claude/settings*.json, .claude/hooks,
.claude/skills, .claude/commands, .claude/agents, .mcp.json, .git, the common git directory's hooks and config, a
linked worktree's git directory, the run directory and the personal
config layer. The same binds read-only are still allowed, as is a
writable bind of a subdirectory that covers none of them. Only an exact
`ro` makes a bind read-only, and podman's `U` beside it counts as
writable.

Generated-by: Claude Opus 5

* container-gateway: refuse overlay mount options

Generated-by: Claude Opus 5

* container-gateway: protect agent config inside nested worktrees

Generated-by: Claude Opus 5

* Tighten adapter defaults: TLS, collaborator rollups, HTTPS-only bridge (#1862)

* Tighten adapter defaults: TLS, collaborator rollups, HTTPS-only bridge

- imap: connect with a verified TLS context (ssl.create_default_context()).
- github-rollup and vetted-ops: a status-rollup comment is read only when
  its author is the authenticated gh user or a repository collaborator.
- jira bridge.groovy: refuse non-HTTPS base URLs and do not follow
  redirects.
- sourcehut: comment bodies go through --body-file only; graphql
  mutations need --allow-mutation; the service must be a bare subdomain.
- jira-patch docs: the reviewed attachment is pinned by id and sha256
  before it is applied.

Generated-by: Claude Opus 5

* Stop on an unverifiable rollup author; end GraphQL comments at CR

Generated-by: Claude Opus 5

* Security workflow: stricter relay detection and advisory URL checks (#1863)

* Security workflow: stricter relay detection and advisory URL checks

- Forwarder relay detection needs both the adapter's sender pattern and
  its preamble to match; a single match is surfaced to the user as "not
  a relay". Relay-derived reporter credit stays a proposal until a human
  confirms it.
- New `security-management issue-sync advisory-archive` check: a Public
  advisory URL is accepted only for a thread on the advisory list whose
  root subject is `<CVE-ID>: <description>`, under the configured
  archive URL, and always with confirmation.
- PonyMail: an empty result for a private list reads as "unknown" unless
  the list is visible to the session.
- Docs: egress-gateway, cve-org, forgejo and mailman3 curl notes, and
  macOS egress in secure-agent-internals.

Generated-by: Claude Opus 5

* advisory-archive: require an authorized sender and a configured archive

Generated-by: Claude Opus 5

* advisory-archive: parse the sender as a mail header

Generated-by: Claude Opus 5

* issue-sync: say the advisory sender is unverified when asking to confirm

Generated-by: Claude Opus 5

* agent-guard: a continued heredoc line is not stripped (#1864)

Generated-by: Claude Opus 5

* setup: import the pre-flight checker only from outside the working tree (#1866)

* setup: import the pre-flight checker only from outside the working tree

Pre-flight now runs `python3 -P -m setup_preflight` with PYTHONPATH set
to the git directory's apache-magpie/ (or .svn/apache-magpie/) only.
`-P` keeps the working tree off the import path, and no in-tree
directory is on it, so a repository cannot ship the module pre-flight
imports. `config` installs the checker into that checker_dir (new
`checker_home()` in layers.py); the regenerated block is in every skill.

The checker refuses a Python older than 3.11 before importing anything
(exit 3, which the block reads as "did not run"), and the test session
refuses to start on one.

For a git-tag install, `upgrade`, `verify` and `install` also compare the
committed lock's commit anchor with the fetched commit and with the tag
resolved upstream. A tag that moved stops the upgrade; re-pinning to it
needs `bump-committed` and an explicit confirmation.

Generated-by: Claude Opus 5

* setup: trust .svn only in a real working copy; check an existing snapshot's commit

Generated-by: Claude Opus 5

* agent-guard: strip heredoc bodies only where bash treats them as data (#1867)

* agent-guard: open a heredoc only on an unquoted, uncommented <<

When a command line cannot be tokenised, the guard strips heredoc bodies
before parsing again. It found the `<<WORD` operators with a regex over
the raw line, so a `<<EOF` inside quotes or a `#` comment counted as a
heredoc, and the lines after it were dropped as its body -- while bash
runs them as commands:

    cat /dev/null # <<EOF it's
    git push --force
    EOF

The operators are now found by a scan that tracks quotes (including
`$'...'` and quotes left open on an earlier line) and stops at a
comment. A body naming git / gh under an unquoted delimiter is also no
longer stripped when it holds a `$(...)` or backtick substitution, which
bash expands even in a heredoc fed to cat or tee.

Generated-by: Claude Opus 5

* agent-guard: read heredoc delimiters and shifts as bash does

Two more places where the heredoc stripper and bash disagreed:

- `<<` inside arithmetic or a parameter expansion (`$[1<<x]`,
  `${a[1<<x]}`) is a shift, not a heredoc, yet its "body" was stripped.
  Such a line now leaves the command unstripped.
- The delimiter was matched as `[A-Za-z_][\w.-]*` with optional
  surrounding quotes, so `E-O:F`, `'E'OF` or `E"O"F` closed the body on
  a different line than bash does. The delimiter is now the whole word
  up to the next metacharacter, after quote removal, and quoted when any
  quote or backslash appears in it; a word with `$` or a backtick is not
  stripped.

A body naming git / gh is also stripped only when the introducing line
is a bare `cat` / `tee` with plain file arguments and `>` / `>>`
redirections.

Generated-by: Claude Opus 5

* agent-guard: strip a heredoc body naming git / gh only from a bare cat

Rather than chasing each shape in which a heredoc fed to cat / tee is not
really data, strip a body naming git or gh only in the narrowest safe
shape, and keep the command whole otherwise:

- the introducing line starts a command (the line before does not end in
  `\`, `|`, `&&` or `||`);
- its first word is exactly `cat`, `tee`, `/bin/cat` or `/usr/bin/cat`;
- the command has no `alias`, no `cat` / `tee` function, and no `$((`,
  `((`, `$[` or `${` anywhere.

Generated-by: Claude Opus 5

* agent-guard: keep a heredoc whole when its output may be run, and see into eval

A body naming git / gh fed to a bare cat was still stripped when the cat
output itself was run: `(cat <<EOF ... ) | bash`, `{ ...; } | bash`.
Instead of matching each wrapper, a body naming git or gh is now
stripped only when the command, with its heredoc bodies removed, holds
no `$`, backtick, parenthesis or brace, no `alias`, and no `eval`,
`source` or `.` in command position. This subsumes the earlier
function and arithmetic checks.

`eval` hid what it ran from every guard, heredoc or not. Its arguments,
joined as bash joins them, are now split and checked like a shell -c
string, one level deep.

Gen…
potiuk added a commit that referenced this pull request Oct 28, 2026
* docs(spec-loop): sync specs with contributed functionality

Back-fill the specs for the 46 commits since the last sync that changed
behaviour without touching a spec: the public-surface scrub mod (#1676),
adapter hardening (#1862), the hook managers with install strategy
`none` (#1838), Linux `allowAllUnixSockets` and its socket cover (#1809),
the touch-overlay dismissal and matcher (#1832, #1833), the per-family
Codex manifests (#1836), Vulnogram reviewer comments (#1840), the verify
false-pass fixes (#1808), gateway refusal hints (#1810), adversarial-review
output and agy fixes (#1852, #1854, #1871), and code-review inline comment
text (#1853).

Also correct drift found on the way: the threat-model pointer now names
Appendix B after the #1859 restructure, the troubleshooting catalogue has
fourteen entries, and the overview's Mentoring and Pairing skill counts
match docs/modes.md.

Generated-by: Claude Opus 5

* docs(marketplace): magpie-security ships its own Codex manifest too

#1676 gave magpie-security a .codex-plugin/plugin.json and an empty
hooks/codex-hooks.json, because its hooks.json now carries a Claude Code
mod, but the "Which manifest Codex reads" table and the Hooks paragraph
still named only magpie-setup and magpie-utilities.

Generated-by: Claude Opus 5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

capability:fix Implement code change to resolve an issue capability:platform Framework / agent substrate skills (install, verify, doctor, override, status, setup bootstrap) capability:reassess Re-run historical issues against current code capability:resolve Close-out: invalidate, dedupe, CVE allocate, announcement family:ci .github workflows, prek, validators family:docs Docs, MISSION.md, READMEs family:security security-* skills family:setup setup-* skills family:tools tools/* substrate:framework-dev Tool substrate: build / validate / eval the framework itself

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants