mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
feat(deslop-shared-libs): route every Git read through bin/gstack-safe-git
The skill made the model retype a long safe-Git prefix on each call and a
dropped flag failed shared-libs-read-only. bin/gstack-safe-git applies the
fixed env + flag prefix, adds --no-ext-diff --no-textconv to log/show/diff,
allows diff only between two explicit object IDs and ls-files only in the
NUL-delimited overlay form, and refuses every other shape with one line
naming the allowed forms. The template now points at the installed helper
(host global runtime via {{SAFE_GIT}}) and drops the prose it enforces.
Fixtures resolve the helper to this checkout, the git shim records the safety
environment, and isGuardedGitRequest requires the complete prefix (env
included) for every repository read.
This commit is contained in:
1 parent
157a5ff520
commit
a367a1f265
13 files changed
+511
-77
No files matched your search
Executable
+149
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# gstack-safe-git — run one allowlisted, read-only Git query for audits that
|
||||||
|
# must not execute project-controlled code (/deslop-shared-libs).
|
||||||
|
#
|
||||||
|
# Usage: gstack-safe-git [-C <dir>] <subcommand> [args...]
|
||||||
|
#
|
||||||
|
# Every invocation runs `git` with this fixed prefix; callers cannot add or
|
||||||
|
# override it:
|
||||||
|
# GIT_OPTIONAL_LOCKS=0 GIT_NO_LAZY_FETCH=1 GIT_TERMINAL_PROMPT=0
|
||||||
|
# git --no-pager --no-lazy-fetch --no-replace-objects
|
||||||
|
# -c core.fsmonitor=false -c log.showSignature=false -c diff.submodule=short
|
||||||
|
#
|
||||||
|
# Only query shapes that cannot run clean/process filters, textconv or external
|
||||||
|
# diff drivers, signature verifiers, pagers, transports, or index/ref writes are
|
||||||
|
# forwarded. log/show/diff always get --no-ext-diff --no-textconv; diff is only
|
||||||
|
# between two explicit object IDs. Everything else is refused with exit 2 and
|
||||||
|
# a one-line message naming the allowed forms. Git's own exit status passes
|
||||||
|
# through unchanged, including 129 when this Git lacks --no-lazy-fetch.
|
||||||
|
#
|
||||||
|
# The script sources nothing and executes only `git` from PATH.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ALLOWED='allowed: rev-parse, symbolic-ref [--short] <ref>, branch --show-current, remote [-v | get-url <name>], config --get|--get-all|--get-regexp <key>, log, show, ls-tree, cat-file, rev-list, merge-base, for-each-ref, show-ref, grep, diff <object-id> <object-id> [-- <path>...], ls-files --cached --others --exclude-standard -z [-- <path>...]'
|
||||||
|
|
||||||
|
refuse() {
|
||||||
|
echo "gstack-safe-git: refused: $1; $ALLOWED" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
dir_args=()
|
||||||
|
if [ "${1:-}" = "-C" ]; then
|
||||||
|
[ $# -ge 2 ] || refuse "-C needs a directory"
|
||||||
|
dir_args=(-C "$2")
|
||||||
|
shift 2
|
||||||
|
fi
|
||||||
|
[ $# -ge 1 ] || refuse "no subcommand"
|
||||||
|
sub=$1
|
||||||
|
shift
|
||||||
|
case "$sub" in
|
||||||
|
-*) refuse "global option '$sub' (only a leading -C <dir> is accepted; the safety -c settings are fixed)" ;;
|
||||||
|
rev-parse|symbolic-ref|branch|remote|config|log|show|ls-tree|cat-file|rev-list|merge-base|for-each-ref|show-ref|grep|diff|ls-files) ;;
|
||||||
|
*) refuse "'$sub' is not an allowlisted read" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
[ "$arg" = "--" ] && break
|
||||||
|
case "$arg" in
|
||||||
|
--output|--output=*) refuse "'$arg' writes files" ;;
|
||||||
|
--ext-diff|--textconv|--filters|--path|--path=*) refuse "'$arg' can run configured diff drivers or filters" ;;
|
||||||
|
--show-signature|*%G*|*'%(signature'*) refuse "'$arg' runs a signature verifier" ;;
|
||||||
|
--no-index|--recurse-submodules) refuse "'$arg' reads outside the repository's committed objects" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
positional_before_dashdash() {
|
||||||
|
local count=0 arg
|
||||||
|
for arg in "$@"; do
|
||||||
|
[ "$arg" = "--" ] && break
|
||||||
|
case "$arg" in -*) ;; *) count=$((count + 1)) ;; esac
|
||||||
|
done
|
||||||
|
echo "$count"
|
||||||
|
}
|
||||||
|
|
||||||
|
extra=()
|
||||||
|
case "$sub" in
|
||||||
|
rev-parse|ls-tree|cat-file|rev-list|merge-base|for-each-ref|show-ref) ;;
|
||||||
|
log|show) extra=(--no-ext-diff --no-textconv) ;;
|
||||||
|
grep)
|
||||||
|
for arg in "$@"; do
|
||||||
|
[ "$arg" = "--" ] && break
|
||||||
|
case "$arg" in
|
||||||
|
-O*|--open-files-in-pager*) refuse "'$arg' launches a pager program" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
symbolic-ref)
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-q|--quiet|--short|--no-recurse) ;;
|
||||||
|
-*) refuse "symbolic-ref '$arg' is not a read" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ "$(positional_before_dashdash "$@")" = 1 ] || refuse "symbolic-ref reads exactly one ref"
|
||||||
|
;;
|
||||||
|
branch)
|
||||||
|
[ "$*" = "--show-current" ] || refuse "branch is limited to 'branch --show-current'"
|
||||||
|
;;
|
||||||
|
remote)
|
||||||
|
case "$*" in
|
||||||
|
''|-v|--verbose) ;;
|
||||||
|
*)
|
||||||
|
[ "${1:-}" = "get-url" ] || refuse "remote is limited to listing and get-url"
|
||||||
|
shift_count=0
|
||||||
|
for arg in "${@:2}"; do
|
||||||
|
case "$arg" in
|
||||||
|
--push|--all) ;;
|
||||||
|
-*) refuse "remote get-url '$arg'" ;;
|
||||||
|
*) shift_count=$((shift_count + 1)) ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ "$shift_count" = 1 ] || refuse "remote get-url takes one remote name"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
config)
|
||||||
|
case "${1:-}" in
|
||||||
|
--get|--get-all|--get-regexp) ;;
|
||||||
|
*) refuse "config is limited to --get, --get-all and --get-regexp" ;;
|
||||||
|
esac
|
||||||
|
[ $# -ge 2 ] && [ $# -le 3 ] || refuse "config reads take a key and an optional value pattern"
|
||||||
|
for arg in "${@:2}"; do
|
||||||
|
case "$arg" in -*) refuse "config '$arg'" ;; esac
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
diff)
|
||||||
|
ids=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
[ "$arg" = "--" ] && break
|
||||||
|
case "$arg" in
|
||||||
|
--cached|--staged|--merge-base|--merge-base=*) refuse "diff '$arg' compares the index or derived revisions" ;;
|
||||||
|
-*) ;;
|
||||||
|
*)
|
||||||
|
[[ "$arg" =~ ^[0-9a-fA-F]{7,64}$ ]] || refuse "diff operand '$arg' is not an explicit object ID (put paths after --)"
|
||||||
|
ids=$((ids + 1))
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ "$ids" = 2 ] || refuse "diff needs exactly two explicit committed object IDs, never the worktree or index"
|
||||||
|
extra=(--no-ext-diff --no-textconv)
|
||||||
|
;;
|
||||||
|
ls-files)
|
||||||
|
nul=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
[ "$arg" = "--" ] && break
|
||||||
|
case "$arg" in
|
||||||
|
-z) nul=1 ;;
|
||||||
|
--cached|--others|--exclude-standard|--stage) ;;
|
||||||
|
*) refuse "ls-files '$arg' (the overlay form is 'ls-files --cached --others --exclude-standard -z [-- <path>...]')" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ "$nul" = 1 ] || refuse "ls-files output must be NUL-delimited with -z"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
unset GIT_EXTERNAL_DIFF GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT
|
||||||
|
export GIT_OPTIONAL_LOCKS=0 GIT_NO_LAZY_FETCH=1 GIT_TERMINAL_PROMPT=0
|
||||||
|
exec git --no-pager --no-lazy-fetch --no-replace-objects \
|
||||||
|
-c core.fsmonitor=false -c log.showSignature=false -c diff.submodule=short \
|
||||||
|
${dir_args[@]+"${dir_args[@]}"} "$sub" ${extra[@]+"${extra[@]}"} "$@"
|
||||||
+13
-28
@@ -66,9 +66,15 @@ changed after writing one.
|
|||||||
A direct HTTP fallback must also return its response on stdout without
|
A direct HTTP fallback must also return its response on stdout without
|
||||||
creating files; do not replace successful authenticated results with an
|
creating files; do not replace successful authenticated results with an
|
||||||
unauthenticated request and then describe the source as inaccessible.
|
unauthenticated request and then describe the source as inaccessible.
|
||||||
3. Before local object reads, probe no-lazy-fetch support using the safe Git
|
3. Run every Git command as `~/.claude/skills/gstack/bin/gstack-safe-git <args>`, never bare `git`;
|
||||||
prefix below and `rev-parse --is-inside-work-tree`. A successful Git version
|
only the exact diagnostic `git --version` may run bare. The helper fixes the
|
||||||
check alone is insufficient. If unsupported, use pinned-commit GET API source
|
no-lazy-fetch, lock, pager, fsmonitor, signature and replacement-object
|
||||||
|
protections and refuses reads that could run filters, drivers, hooks or
|
||||||
|
transports, naming the allowed forms. Never bypass a refusal with raw `git`.
|
||||||
|
`diff` takes exactly two explicit committed object IDs, then `--` and paths.
|
||||||
|
First probe with `~/.claude/skills/gstack/bin/gstack-safe-git rev-parse --is-inside-work-tree`; a Git
|
||||||
|
version check alone is insufficient. If the probe fails (for example
|
||||||
|
`unknown option: --no-lazy-fetch`), use pinned-commit GET API source
|
||||||
and history reads or disclose unavailable local-history coverage. Never retry
|
and history reads or disclose unavailable local-history coverage. Never retry
|
||||||
object reads without the no-lazy-fetch protection, including by decoding loose
|
object reads without the no-lazy-fetch protection, including by decoding loose
|
||||||
objects or packfiles directly. After an unsupported probe, do not inspect Git
|
objects or packfiles directly. After an unsupported probe, do not inspect Git
|
||||||
@@ -79,31 +85,10 @@ changed after writing one.
|
|||||||
unavailable, continue with clearly labeled raw source and unknown tracking
|
unavailable, continue with clearly labeled raw source and unknown tracking
|
||||||
status and revision/history coverage.
|
status and revision/history coverage.
|
||||||
|
|
||||||
The exact diagnostic `git --version` may run without the prefix below: it does
|
Do not execute scripts from the audited project, even to inspect it. For the
|
||||||
not read repository state or execute configured hooks. It never substitutes for
|
uncommitted overlay, enumerate tracked and nonignored untracked paths with
|
||||||
the guarded capability probe. For every other Git invocation disable optional
|
`~/.claude/skills/gstack/bin/gstack-safe-git ls-files --cached --others --exclude-standard -z`, then inspect
|
||||||
locks, pager, fsmonitor, signature verification, replacement objects and lazy fetch.
|
raw source with the host's read tools or isolated standard-library reads.
|
||||||
Signature display can execute a
|
|
||||||
configured project verifier. Replacement refs must not substitute different contents
|
|
||||||
under a cited commit ID. Keep submodule diffs short rather than reading their trees.
|
|
||||||
Use this prefix, including for the capability probe:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GIT_OPTIONAL_LOCKS=0 GIT_NO_LAZY_FETCH=1 GIT_TERMINAL_PROMPT=0 \
|
|
||||||
git --no-pager --no-lazy-fetch --no-replace-objects \
|
|
||||||
-c core.fsmonitor=false -c log.showSignature=false -c diff.submodule=short
|
|
||||||
```
|
|
||||||
|
|
||||||
Restrict `git diff` to **two explicit committed object IDs**, with
|
|
||||||
`--no-ext-diff --no-textconv` and `--` before paths. Use the same disabling
|
|
||||||
flags for patch-producing `log`/`show` commands. Never use worktree/index diffs,
|
|
||||||
`git status`, temporary indexes, `add`, `hash-object --path`, or other
|
|
||||||
normalization helpers: these can execute clean/process filters or alter the index.
|
|
||||||
Do not execute scripts from the audited project, even to inspect it.
|
|
||||||
|
|
||||||
For the uncommitted overlay, enumerate tracked and nonignored untracked paths with
|
|
||||||
guarded, NUL-delimited `ls-files --cached --others --exclude-standard -z`, then
|
|
||||||
inspect raw source with the host's read tools or isolated standard-library reads.
|
|
||||||
For Python reads, use a trusted interpreter with `python3 -I -S`: repository-local
|
For Python reads, use a trusted interpreter with `python3 -I -S`: repository-local
|
||||||
modules can shadow standard-library imports and execute code or write bytecode.
|
modules can shadow standard-library imports and execute code or write bytecode.
|
||||||
Do not add project paths to imports, import project modules, or use runtimes that
|
Do not add project paths to imports, import project modules, or use runtimes that
|
||||||
|
|||||||
@@ -60,9 +60,15 @@ changed after writing one.
|
|||||||
A direct HTTP fallback must also return its response on stdout without
|
A direct HTTP fallback must also return its response on stdout without
|
||||||
creating files; do not replace successful authenticated results with an
|
creating files; do not replace successful authenticated results with an
|
||||||
unauthenticated request and then describe the source as inaccessible.
|
unauthenticated request and then describe the source as inaccessible.
|
||||||
3. Before local object reads, probe no-lazy-fetch support using the safe Git
|
3. Run every Git command as `{{SAFE_GIT}} <args>`, never bare `git`;
|
||||||
prefix below and `rev-parse --is-inside-work-tree`. A successful Git version
|
only the exact diagnostic `git --version` may run bare. The helper fixes the
|
||||||
check alone is insufficient. If unsupported, use pinned-commit GET API source
|
no-lazy-fetch, lock, pager, fsmonitor, signature and replacement-object
|
||||||
|
protections and refuses reads that could run filters, drivers, hooks or
|
||||||
|
transports, naming the allowed forms. Never bypass a refusal with raw `git`.
|
||||||
|
`diff` takes exactly two explicit committed object IDs, then `--` and paths.
|
||||||
|
First probe with `{{SAFE_GIT}} rev-parse --is-inside-work-tree`; a Git
|
||||||
|
version check alone is insufficient. If the probe fails (for example
|
||||||
|
`unknown option: --no-lazy-fetch`), use pinned-commit GET API source
|
||||||
and history reads or disclose unavailable local-history coverage. Never retry
|
and history reads or disclose unavailable local-history coverage. Never retry
|
||||||
object reads without the no-lazy-fetch protection, including by decoding loose
|
object reads without the no-lazy-fetch protection, including by decoding loose
|
||||||
objects or packfiles directly. After an unsupported probe, do not inspect Git
|
objects or packfiles directly. After an unsupported probe, do not inspect Git
|
||||||
@@ -73,31 +79,10 @@ changed after writing one.
|
|||||||
unavailable, continue with clearly labeled raw source and unknown tracking
|
unavailable, continue with clearly labeled raw source and unknown tracking
|
||||||
status and revision/history coverage.
|
status and revision/history coverage.
|
||||||
|
|
||||||
The exact diagnostic `git --version` may run without the prefix below: it does
|
Do not execute scripts from the audited project, even to inspect it. For the
|
||||||
not read repository state or execute configured hooks. It never substitutes for
|
uncommitted overlay, enumerate tracked and nonignored untracked paths with
|
||||||
the guarded capability probe. For every other Git invocation disable optional
|
`{{SAFE_GIT}} ls-files --cached --others --exclude-standard -z`, then inspect
|
||||||
locks, pager, fsmonitor, signature verification, replacement objects and lazy fetch.
|
raw source with the host's read tools or isolated standard-library reads.
|
||||||
Signature display can execute a
|
|
||||||
configured project verifier. Replacement refs must not substitute different contents
|
|
||||||
under a cited commit ID. Keep submodule diffs short rather than reading their trees.
|
|
||||||
Use this prefix, including for the capability probe:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GIT_OPTIONAL_LOCKS=0 GIT_NO_LAZY_FETCH=1 GIT_TERMINAL_PROMPT=0 \
|
|
||||||
git --no-pager --no-lazy-fetch --no-replace-objects \
|
|
||||||
-c core.fsmonitor=false -c log.showSignature=false -c diff.submodule=short
|
|
||||||
```
|
|
||||||
|
|
||||||
Restrict `git diff` to **two explicit committed object IDs**, with
|
|
||||||
`--no-ext-diff --no-textconv` and `--` before paths. Use the same disabling
|
|
||||||
flags for patch-producing `log`/`show` commands. Never use worktree/index diffs,
|
|
||||||
`git status`, temporary indexes, `add`, `hash-object --path`, or other
|
|
||||||
normalization helpers: these can execute clean/process filters or alter the index.
|
|
||||||
Do not execute scripts from the audited project, even to inspect it.
|
|
||||||
|
|
||||||
For the uncommitted overlay, enumerate tracked and nonignored untracked paths with
|
|
||||||
guarded, NUL-delimited `ls-files --cached --others --exclude-standard -z`, then
|
|
||||||
inspect raw source with the host's read tools or isolated standard-library reads.
|
|
||||||
For Python reads, use a trusted interpreter with `python3 -I -S`: repository-local
|
For Python reads, use a trusted interpreter with `python3 -I -S`: repository-local
|
||||||
modules can shadow standard-library imports and execute code or write bytecode.
|
modules can shadow standard-library imports and execute code or write bytecode.
|
||||||
Do not add project paths to imports, import project modules, or use runtimes that
|
Do not add project paths to imports, import project modules, or use runtimes that
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import { generateThirdPartyActions } from './third-party-actions';
|
|||||||
import { generateAsideSetup, generateAsideCookbook, generateAsideResearch, generateUntrustedContentWarning, asideExecPrelude } from './aside';
|
import { generateAsideSetup, generateAsideCookbook, generateAsideResearch, generateUntrustedContentWarning, asideExecPrelude } from './aside';
|
||||||
import { generateCommandReference, generateSnapshotFlags, generateBrowseSetup, generateBrowseFallback } from './browse';
|
import { generateCommandReference, generateSnapshotFlags, generateBrowseSetup, generateBrowseFallback } from './browse';
|
||||||
import { generateDesignDocDiscovery } from './design-doc-discovery';
|
import { generateDesignDocDiscovery } from './design-doc-discovery';
|
||||||
import { generateSharedLibsRubric } from './shared-libs';
|
import { generateSharedLibsRubric, generateSafeGitPath } from './shared-libs';
|
||||||
import { generateTestValueBar, generateTestValueMessage } from './test-value';
|
import { generateTestValueBar, generateTestValueMessage } from './test-value';
|
||||||
import { generateQAScope, generateQAExploratory, generateQAFunctional, generateQAResource, generateQAReview, generateQAReviewPreflight, generateQAMethodReads } from './qa';
|
import { generateQAScope, generateQAExploratory, generateQAFunctional, generateQAResource, generateQAReview, generateQAReviewPreflight, generateQAMethodReads } from './qa';
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@ export const RESOLVERS: Record<string, ResolverFn> = {
|
|||||||
THIRD_PARTY_ACTIONS: generateThirdPartyActions,
|
THIRD_PARTY_ACTIONS: generateThirdPartyActions,
|
||||||
DESIGN_DOC_DISCOVERY: generateDesignDocDiscovery,
|
DESIGN_DOC_DISCOVERY: generateDesignDocDiscovery,
|
||||||
SHARED_LIBS_RUBRIC: generateSharedLibsRubric,
|
SHARED_LIBS_RUBRIC: generateSharedLibsRubric,
|
||||||
|
SAFE_GIT: generateSafeGitPath,
|
||||||
SHARED_CODE_REUSE: generateSharedCodeReuse,
|
SHARED_CODE_REUSE: generateSharedCodeReuse,
|
||||||
UNTRUSTED_CONTENT_WARNING: generateUntrustedContentWarning,
|
UNTRUSTED_CONTENT_WARNING: generateUntrustedContentWarning,
|
||||||
COMMAND_REFERENCE: generateCommandReference,
|
COMMAND_REFERENCE: generateCommandReference,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { ResolverFn } from './types';
|
import type { ResolverFn } from './types';
|
||||||
|
import { getHostConfig } from '../../hosts/index';
|
||||||
|
|
||||||
|
/** The installed read-only Git helper. Always the trusted global runtime, never a repo-local root. */
|
||||||
|
export const generateSafeGitPath: ResolverFn = (ctx) => `~/${getHostConfig(ctx.host).globalRoot}/bin/gstack-safe-git`;
|
||||||
|
|
||||||
/** Shared criteria only: the caller owns scope, output, and permission to act. */
|
/** Shared criteria only: the caller owns scope, output, and permission to act. */
|
||||||
export const generateSharedLibsRubric: ResolverFn = () => `### Shared-code evaluation rubric
|
export const generateSharedLibsRubric: ResolverFn = () => `### Shared-code evaluation rubric
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { e2eTierEnabled } from './helpers/e2e-gate';
|
|||||||
import { EvalCollector } from './helpers/eval-store';
|
import { EvalCollector } from './helpers/eval-store';
|
||||||
import { detectBaseBranch, E2E_TOUCHFILES, getChangedFiles, GLOBAL_TOUCHFILES, selectTests } from './helpers/touchfiles';
|
import { detectBaseBranch, E2E_TOUCHFILES, getChangedFiles, GLOBAL_TOUCHFILES, selectTests } from './helpers/touchfiles';
|
||||||
import {
|
import {
|
||||||
createSharedLibsFixture, installHostileGitConfig, installSourceShims, readRequests,
|
createSharedLibsFixture, installHostileGitConfig, installSourceShims, isGuardedGitRequest, readRequests,
|
||||||
seedOpportunitySources, sharedReadOnlyViolations, SHARED_LIBS_ROOT, snapshotFixture,
|
seedOpportunitySources, sharedReadOnlyViolations, SHARED_LIBS_ROOT, snapshotFixture,
|
||||||
} from './helpers/shared-libs-eval-fixture';
|
} from './helpers/shared-libs-eval-fixture';
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ describeCodex('Shared-code audit on live Codex (periodic)', () => {
|
|||||||
result = await runCodexSkill({
|
result = await runCodexSkill({
|
||||||
skillDir: path.join(SHARED_LIBS_ROOT, '.agents/skills/gstack-deslop-shared-libs'),
|
skillDir: path.join(SHARED_LIBS_ROOT, '.agents/skills/gstack-deslop-shared-libs'),
|
||||||
skillName: 'deslop-shared-libs',
|
skillName: 'deslop-shared-libs',
|
||||||
|
runtimeRoot: SHARED_LIBS_ROOT,
|
||||||
// Extract the actual generated Codex workflow, retaining all standalone rules
|
// Extract the actual generated Codex workflow, retaining all standalone rules
|
||||||
// and its common rubric without importing an unrelated parent preamble.
|
// and its common rubric without importing an unrelated parent preamble.
|
||||||
sections: [
|
sections: [
|
||||||
@@ -111,9 +112,7 @@ describeCodex('Shared-code audit on live Codex (periodic)', () => {
|
|||||||
for (const forbidden of ['status', 'fetch', 'ls-remote', 'pull', 'push', 'clone', 'add', 'write-tree', 'hash-object', 'checkout', 'reset']) {
|
for (const forbidden of ['status', 'fetch', 'ls-remote', 'pull', 'push', 'clone', 'add', 'write-tree', 'hash-object', 'checkout', 'reset']) {
|
||||||
expect(request.args).not.toContain(forbidden);
|
expect(request.args).not.toContain(forbidden);
|
||||||
}
|
}
|
||||||
expect(request.args).toContain('--no-lazy-fetch');
|
expect(isGuardedGitRequest(request), JSON.stringify(request)).toBe(true);
|
||||||
expect(request.args).toContain('core.fsmonitor=false');
|
|
||||||
expect(request.args).toContain('log.showSignature=false');
|
|
||||||
}
|
}
|
||||||
const apiReads = requests.filter(row => (row.tool === 'gh' && row.args[0] === 'api') || row.tool === 'curl');
|
const apiReads = requests.filter(row => (row.tool === 'gh' && row.args[0] === 'api') || row.tool === 'curl');
|
||||||
expect(apiReads.length).toBeGreaterThan(0);
|
expect(apiReads.length).toBeGreaterThan(0);
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* bin/gstack-safe-git: the only Git entry point /deslop-shared-libs allows.
|
||||||
|
* A fake `git` first on PATH records the exact argv and environment the real
|
||||||
|
* script sends, then delegates to the real Git so hostile repository config
|
||||||
|
* proves which forms can and cannot execute project-controlled programs.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const SCRIPT = path.join(import.meta.dir, '..', 'bin', 'gstack-safe-git');
|
||||||
|
const REAL_GIT = Bun.which('git') || 'git';
|
||||||
|
const NODE = Bun.which('node') || process.execPath;
|
||||||
|
const PREFIX = ['--no-pager', '--no-lazy-fetch', '--no-replace-objects',
|
||||||
|
'-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', '-c', 'diff.submodule=short'];
|
||||||
|
const RECORDED_ENV = ['GIT_OPTIONAL_LOCKS', 'GIT_NO_LAZY_FETCH', 'GIT_TERMINAL_PROMPT',
|
||||||
|
'GIT_EXTERNAL_DIFF', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT'];
|
||||||
|
|
||||||
|
let root = '', repo = '', trace = '', marker = '', base = '', head = '';
|
||||||
|
let env: Record<string, string> = {};
|
||||||
|
|
||||||
|
const git = (...args: string[]) => {
|
||||||
|
const result = spawnSync(REAL_GIT, args, { cwd: repo, encoding: 'utf8', timeout: 10_000, env });
|
||||||
|
if (result.status !== 0) throw new Error(`git ${args.join(' ')}: ${result.stderr}`);
|
||||||
|
return result.stdout.trim();
|
||||||
|
};
|
||||||
|
const safeGit = (args: string[], extraEnv: Record<string, string> = {}, cwd = repo) =>
|
||||||
|
spawnSync(SCRIPT, args, { cwd, encoding: 'utf8', timeout: 10_000, env: { ...env, ...extraEnv } });
|
||||||
|
const recorded = (): Array<{ args: string[]; env: Record<string, string> }> =>
|
||||||
|
fs.existsSync(trace) ? fs.readFileSync(trace, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line)) : [];
|
||||||
|
const hooks = () => fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8') : '';
|
||||||
|
const reset = () => { fs.rmSync(trace, { force: true }); fs.rmSync(marker, { force: true }); };
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-safe-git-'));
|
||||||
|
repo = path.join(root, 'repo');
|
||||||
|
trace = path.join(root, 'git-calls.jsonl');
|
||||||
|
marker = path.join(root, 'hooks.log');
|
||||||
|
const bin = path.join(root, 'bin');
|
||||||
|
fs.mkdirSync(repo);
|
||||||
|
fs.mkdirSync(bin);
|
||||||
|
const gitConfig = path.join(root, 'gitconfig');
|
||||||
|
fs.writeFileSync(gitConfig, '');
|
||||||
|
env = { ...process.env as Record<string, string>, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: gitConfig,
|
||||||
|
PATH: `${bin}${path.delimiter}${process.env.PATH || ''}` };
|
||||||
|
for (const key of RECORDED_ENV) delete env[key];
|
||||||
|
// This Git may predate --no-lazy-fetch (2.44); the recorded argv is still exactly what the script sent.
|
||||||
|
const lazyFlag = spawnSync(REAL_GIT, ['--no-lazy-fetch', '--version'], { encoding: 'utf8', timeout: 10_000 }).status === 0;
|
||||||
|
fs.writeFileSync(path.join(bin, 'git'), `#!${NODE}
|
||||||
|
const fs = require('node:fs'), cp = require('node:child_process');
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const env = Object.fromEntries(${JSON.stringify(RECORDED_ENV)}.filter(k => k in process.env).map(k => [k, process.env[k]]));
|
||||||
|
fs.appendFileSync(${JSON.stringify(trace)}, JSON.stringify({ args, env }) + '\\n');
|
||||||
|
if (process.env.FAKE_GIT_EXIT) { process.stderr.write('unknown option: --no-lazy-fetch\\n'); process.exit(Number(process.env.FAKE_GIT_EXIT)); }
|
||||||
|
const forwarded = ${lazyFlag} ? args : args.filter(arg => arg !== '--no-lazy-fetch');
|
||||||
|
const result = cp.spawnSync(${JSON.stringify(REAL_GIT)}, forwarded, { stdio: 'inherit', timeout: 10_000 });
|
||||||
|
process.exit(result.status ?? 1);
|
||||||
|
`, { mode: 0o755 });
|
||||||
|
|
||||||
|
git('init', '-b', 'main');
|
||||||
|
git('config', 'user.name', 'Safe Git Fixture');
|
||||||
|
git('config', 'user.email', 'safe-git@example.invalid');
|
||||||
|
git('remote', 'add', 'origin', 'https://example.invalid/fixture.git');
|
||||||
|
fs.writeFileSync(path.join(repo, 'tracked.txt'), 'hello\n');
|
||||||
|
fs.writeFileSync(path.join(repo, '.gitattributes'), 'tracked.txt filter=probe diff=probe\n');
|
||||||
|
git('add', '.');
|
||||||
|
git('commit', '-m', 'base');
|
||||||
|
base = git('rev-parse', 'HEAD');
|
||||||
|
fs.writeFileSync(path.join(repo, 'tracked.txt'), 'hello world\n');
|
||||||
|
git('commit', '-am', 'change');
|
||||||
|
// A signature header makes plain log reads consult the configured verifier.
|
||||||
|
const signed = git('cat-file', 'commit', 'HEAD').replace('\n\n',
|
||||||
|
'\ngpgsig -----BEGIN PGP SIGNATURE-----\n dummy\n -----END PGP SIGNATURE-----\n\n') + '\n';
|
||||||
|
const written = spawnSync(REAL_GIT, ['hash-object', '-t', 'commit', '-w', '--stdin'],
|
||||||
|
{ cwd: repo, input: signed, encoding: 'utf8', timeout: 10_000, env });
|
||||||
|
head = written.stdout.trim();
|
||||||
|
git('update-ref', 'HEAD', head);
|
||||||
|
const hook = (name: string, body: string) => {
|
||||||
|
const file = path.join(root, name);
|
||||||
|
fs.writeFileSync(file, `#!/bin/sh\necho ${name} >> '${marker}'\n${body}\n`, { mode: 0o755 });
|
||||||
|
return file;
|
||||||
|
};
|
||||||
|
git('config', 'filter.probe.clean', hook('clean-hook', 'cat'));
|
||||||
|
git('config', 'diff.probe.textconv', hook('textconv-hook', 'cat "$1"'));
|
||||||
|
git('config', 'diff.external', hook('external-diff-hook', 'exit 0'));
|
||||||
|
git('config', 'core.fsmonitor', hook('fsmonitor-hook', 'exit 1'));
|
||||||
|
git('config', 'gpg.program', hook('gpg-hook', 'exit 1'));
|
||||||
|
git('config', 'log.showSignature', 'true');
|
||||||
|
// A raw worktree edit: any index refresh or worktree diff would run the clean filter.
|
||||||
|
fs.writeFileSync(path.join(repo, 'tracked.txt'), 'hello raw overlay\n');
|
||||||
|
fs.writeFileSync(path.join(repo, 'untracked.txt'), 'new\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => { if (root) fs.rmSync(root, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
describe('gstack-safe-git', () => {
|
||||||
|
test('the hostile config is live: equivalent raw Git reads execute every canary', () => {
|
||||||
|
reset();
|
||||||
|
for (const args of [['diff', base, head], ['log', '-1'], ['status'], ['diff', '--no-ext-diff', base, head],
|
||||||
|
['hash-object', '--path', 'tracked.txt', 'tracked.txt']]) {
|
||||||
|
spawnSync(REAL_GIT, args, { cwd: repo, encoding: 'utf8', timeout: 10_000, env });
|
||||||
|
}
|
||||||
|
for (const name of ['external-diff-hook', 'gpg-hook', 'fsmonitor-hook', 'clean-hook', 'textconv-hook']) {
|
||||||
|
expect(hooks()).toContain(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('always applies the fixed prefix and environment, and strips caller config overrides', () => {
|
||||||
|
reset();
|
||||||
|
const result = safeGit(['rev-parse', '--is-inside-work-tree'], {
|
||||||
|
GIT_CONFIG_PARAMETERS: "'core.fsmonitor'='/bin/false'", GIT_CONFIG_COUNT: '1',
|
||||||
|
GIT_EXTERNAL_DIFF: path.join(root, 'external-diff-hook'),
|
||||||
|
});
|
||||||
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
expect(result.stdout.trim()).toBe('true');
|
||||||
|
expect(recorded()).toEqual([{ args: [...PREFIX, 'rev-parse', '--is-inside-work-tree'],
|
||||||
|
env: { GIT_OPTIONAL_LOCKS: '0', GIT_NO_LAZY_FETCH: '1', GIT_TERMINAL_PROMPT: '0' } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const permitted = (): Array<[string[], string[]]> => [
|
||||||
|
[['rev-parse', 'HEAD'], ['rev-parse', 'HEAD']],
|
||||||
|
[['symbolic-ref', '--short', 'HEAD'], ['symbolic-ref', '--short', 'HEAD']],
|
||||||
|
[['branch', '--show-current'], ['branch', '--show-current']],
|
||||||
|
[['remote', '-v'], ['remote', '-v']],
|
||||||
|
[['remote', 'get-url', 'origin'], ['remote', 'get-url', 'origin']],
|
||||||
|
[['config', '--get', 'remote.origin.url'], ['config', '--get', 'remote.origin.url']],
|
||||||
|
[['log', '-p', '--format=%H %s', '-2'], ['log', '--no-ext-diff', '--no-textconv', '-p', '--format=%H %s', '-2']],
|
||||||
|
[['show', head], ['show', '--no-ext-diff', '--no-textconv', head]],
|
||||||
|
[['show', `${head}:tracked.txt`], ['show', '--no-ext-diff', '--no-textconv', `${head}:tracked.txt`]],
|
||||||
|
[['ls-tree', '-r', head], ['ls-tree', '-r', head]],
|
||||||
|
[['cat-file', '-p', head], ['cat-file', '-p', head]],
|
||||||
|
[['rev-list', '--count', head], ['rev-list', '--count', head]],
|
||||||
|
[['merge-base', base, head], ['merge-base', base, head]],
|
||||||
|
[['for-each-ref', '--format=%(refname)'], ['for-each-ref', '--format=%(refname)']],
|
||||||
|
[['show-ref'], ['show-ref']],
|
||||||
|
[['grep', '-n', 'hello', head, '--', 'tracked.txt'], ['grep', '-n', 'hello', head, '--', 'tracked.txt']],
|
||||||
|
[['diff', base, head, '--', 'tracked.txt'], ['diff', '--no-ext-diff', '--no-textconv', base, head, '--', 'tracked.txt']],
|
||||||
|
[['diff', '--stat', base.slice(0, 12), head], ['diff', '--no-ext-diff', '--no-textconv', '--stat', base.slice(0, 12), head]],
|
||||||
|
[['ls-files', '--cached', '--others', '--exclude-standard', '-z'], ['ls-files', '--cached', '--others', '--exclude-standard', '-z']],
|
||||||
|
[['ls-files', '--stage', '-z', '--', 'tracked.txt'], ['ls-files', '--stage', '-z', '--', 'tracked.txt']],
|
||||||
|
];
|
||||||
|
|
||||||
|
test('forwards every permitted read with patch drivers disabled and runs no project program', () => {
|
||||||
|
for (const [args, forwarded] of permitted()) {
|
||||||
|
reset();
|
||||||
|
const result = safeGit(args);
|
||||||
|
expect(result.status, `${args.join(' ')}: ${result.stderr}`).toBe(0);
|
||||||
|
expect(recorded().map(row => row.args), args.join(' ')).toEqual([[...PREFIX, ...forwarded]]);
|
||||||
|
expect(hooks(), args.join(' ')).toBe('');
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
const overlay = safeGit(['ls-files', '--cached', '--others', '--exclude-standard', '-z']);
|
||||||
|
expect(overlay.stdout.split('\0').filter(Boolean).sort()).toEqual(['.gitattributes', 'tracked.txt', 'untracked.txt']);
|
||||||
|
const patch = safeGit(['diff', base, head, '--', 'tracked.txt']);
|
||||||
|
expect(patch.stdout).toContain('+hello world');
|
||||||
|
expect(safeGit(['show', `${head}:tracked.txt`]).stdout).toBe('hello world\n');
|
||||||
|
const fromOutside = safeGit(['-C', repo, 'rev-parse', 'HEAD'], {}, root);
|
||||||
|
expect(fromOutside.stdout.trim()).toBe(head);
|
||||||
|
expect(hooks()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
const refused: Array<[string[], RegExp]> = [
|
||||||
|
[[], /no subcommand/],
|
||||||
|
[['status'], /'status' is not an allowlisted read/],
|
||||||
|
[['add', 'tracked.txt'], /'add' is not an allowlisted read/],
|
||||||
|
[['hash-object', '--path', 'tracked.txt', 'tracked.txt'], /'hash-object' is not an allowlisted read/],
|
||||||
|
[['update-index', '--refresh'], /not an allowlisted read/],
|
||||||
|
[['write-tree'], /not an allowlisted read/],
|
||||||
|
[['fetch', 'origin'], /not an allowlisted read/],
|
||||||
|
[['ls-remote', 'origin'], /not an allowlisted read/],
|
||||||
|
[['checkout', 'main'], /not an allowlisted read/],
|
||||||
|
[['blame', 'tracked.txt'], /not an allowlisted read/],
|
||||||
|
[['-c', 'core.fsmonitor=/bin/true', 'log'], /global option '-c'/],
|
||||||
|
[['--git-dir=.git', 'log'], /global option/],
|
||||||
|
[['-C'], /-C needs a directory/],
|
||||||
|
[['diff'], /exactly two explicit committed object IDs/],
|
||||||
|
[['diff', 'HEAD~1', 'HEAD'], /'HEAD~1' is not an explicit object ID/],
|
||||||
|
[['diff', '--cached', 'BASE', 'HEAD'], /compares the index/],
|
||||||
|
[['diff', '--merge-base', 'BASE', 'HEAD'], /compares the index/],
|
||||||
|
[['diff', 'BASE', 'tracked.txt'], /'tracked.txt' is not an explicit object ID/],
|
||||||
|
[['diff', 'BASE', '--', 'tracked.txt'], /exactly two explicit committed object IDs/],
|
||||||
|
[['diff', '--no-index', 'a', 'b'], /'--no-index'/],
|
||||||
|
[['diff', '--ext-diff', 'BASE', 'HEAD'], /diff drivers or filters/],
|
||||||
|
[['log', '--output=out.patch', '-p'], /writes files/],
|
||||||
|
[['log', '-p', '--output', 'out.patch'], /writes files/],
|
||||||
|
[['log', '--show-signature'], /signature verifier/],
|
||||||
|
[['log', '--format=%G?'], /signature verifier/],
|
||||||
|
[['for-each-ref', '--format=%(signature)'], /signature verifier/],
|
||||||
|
[['show', '--textconv', 'HEAD:tracked.txt'], /diff drivers or filters/],
|
||||||
|
[['cat-file', '--filters', 'HEAD:tracked.txt'], /diff drivers or filters/],
|
||||||
|
[['cat-file', '--textconv', 'HEAD:tracked.txt'], /diff drivers or filters/],
|
||||||
|
[['grep', '-Ocat', 'hello'], /launches a pager program/],
|
||||||
|
[['grep', '--open-files-in-pager=cat', 'hello'], /launches a pager program/],
|
||||||
|
[['grep', '--recurse-submodules', 'hello'], /reads outside/],
|
||||||
|
[['ls-files', '--cached', '--others', '--exclude-standard'], /NUL-delimited with -z/],
|
||||||
|
[['ls-files', '--modified', '-z'], /ls-files '--modified'/],
|
||||||
|
[['ls-files', '-z', '--deleted'], /ls-files '--deleted'/],
|
||||||
|
[['symbolic-ref', 'HEAD', 'refs/heads/other'], /exactly one ref/],
|
||||||
|
[['symbolic-ref', '-d', 'HEAD'], /is not a read/],
|
||||||
|
[['branch', 'other'], /branch --show-current/],
|
||||||
|
[['remote', 'add', 'x', 'https://example.invalid/x.git'], /listing and get-url/],
|
||||||
|
[['remote', 'show', 'origin'], /listing and get-url/],
|
||||||
|
[['config', 'user.name', 'x'], /--get, --get-all and --get-regexp/],
|
||||||
|
[['config', '--get', 'user.name', '--unset'], /config '--unset'/],
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(refused)('refuses %j with one actionable line and never starts Git', (args, reason) => {
|
||||||
|
reset();
|
||||||
|
const concrete = args.map(arg => arg === 'BASE' ? base : arg === 'HEAD' && args[0] === 'diff' ? head : arg);
|
||||||
|
const result = safeGit(concrete);
|
||||||
|
expect(result.status).toBe(2);
|
||||||
|
expect(result.stdout).toBe('');
|
||||||
|
expect(result.stderr.trimEnd().split('\n')).toHaveLength(1);
|
||||||
|
expect(result.stderr).toMatch(/^gstack-safe-git: refused: /);
|
||||||
|
expect(result.stderr).toMatch(reason);
|
||||||
|
expect(result.stderr).toContain('; allowed: rev-parse');
|
||||||
|
expect(result.stderr).toContain('diff <object-id> <object-id> [-- <path>...]');
|
||||||
|
expect(result.stderr).toContain('ls-files --cached --others --exclude-standard -z');
|
||||||
|
expect(recorded()).toEqual([]);
|
||||||
|
expect(hooks()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes Git's exit status and stderr through unchanged", () => {
|
||||||
|
reset();
|
||||||
|
const unsupported = safeGit(['rev-parse', '--is-inside-work-tree'], { FAKE_GIT_EXIT: '129' });
|
||||||
|
expect(unsupported.status).toBe(129);
|
||||||
|
expect(unsupported.stderr).toBe('unknown option: --no-lazy-fetch\n');
|
||||||
|
const missing = safeGit(['rev-parse', '--verify', '--quiet', 'refs/heads/absent']);
|
||||||
|
expect(missing.status).toBe(1);
|
||||||
|
const badObject = safeGit(['cat-file', '-t', '0'.repeat(40)]);
|
||||||
|
expect(badObject.status).toBe(128);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -133,6 +133,7 @@ export function installSkillToTempHome(
|
|||||||
skillName: string,
|
skillName: string,
|
||||||
tempHome?: string,
|
tempHome?: string,
|
||||||
sections?: string[],
|
sections?: string[],
|
||||||
|
runtimeRoot?: string,
|
||||||
): string {
|
): string {
|
||||||
const home = tempHome || fs.mkdtempSync(path.join(os.tmpdir(), 'codex-e2e-'));
|
const home = tempHome || fs.mkdtempSync(path.join(os.tmpdir(), 'codex-e2e-'));
|
||||||
const destDir = path.join(home, '.codex', 'skills', skillName);
|
const destDir = path.join(home, '.codex', 'skills', skillName);
|
||||||
@@ -149,6 +150,11 @@ export function installSkillToTempHome(
|
|||||||
// nonexistent skill in its response and otherwise pass discovery checks.
|
// nonexistent skill in its response and otherwise pass discovery checks.
|
||||||
fs.copyFileSync(srcSkill, path.join(destDir, 'SKILL.md'));
|
fs.copyFileSync(srcSkill, path.join(destDir, 'SKILL.md'));
|
||||||
}
|
}
|
||||||
|
if (runtimeRoot) {
|
||||||
|
// The temp HOME has no installed gstack runtime; point runtime helpers at the one under test.
|
||||||
|
const installed = path.join(destDir, 'SKILL.md');
|
||||||
|
fs.writeFileSync(installed, fs.readFileSync(installed, 'utf8').replaceAll('~/.codex/skills/gstack', runtimeRoot));
|
||||||
|
}
|
||||||
|
|
||||||
const srcOpenAIYaml = path.join(skillDir, 'agents', 'openai.yaml');
|
const srcOpenAIYaml = path.join(skillDir, 'agents', 'openai.yaml');
|
||||||
if (fs.existsSync(srcOpenAIYaml)) {
|
if (fs.existsSync(srcOpenAIYaml)) {
|
||||||
@@ -180,6 +186,7 @@ export async function runCodexSkill(opts: {
|
|||||||
configOverrides?: string[]; // TOML key=value overrides (passed with -c)
|
configOverrides?: string[]; // TOML key=value overrides (passed with -c)
|
||||||
ignoreUserConfig?: boolean; // Add --ignore-user-config; auth still comes from CODEX_HOME
|
ignoreUserConfig?: boolean; // Add --ignore-user-config; auth still comes from CODEX_HOME
|
||||||
signal?: AbortSignal; // Abort the process group when an enclosing eval expires
|
signal?: AbortSignal; // Abort the process group when an enclosing eval expires
|
||||||
|
runtimeRoot?: string; // gstack runtime that ~/.codex/skills/gstack helper paths resolve to
|
||||||
}): Promise<CodexResult> {
|
}): Promise<CodexResult> {
|
||||||
const {
|
const {
|
||||||
skillDir,
|
skillDir,
|
||||||
@@ -193,6 +200,7 @@ export async function runCodexSkill(opts: {
|
|||||||
configOverrides = [],
|
configOverrides = [],
|
||||||
ignoreUserConfig = false,
|
ignoreUserConfig = false,
|
||||||
signal,
|
signal,
|
||||||
|
runtimeRoot,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -223,7 +231,7 @@ export async function runCodexSkill(opts: {
|
|||||||
const realHome = os.homedir();
|
const realHome = os.homedir();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
installSkillToTempHome(skillDir, name, tempHome, sections);
|
installSkillToTempHome(skillDir, name, tempHome, sections, runtimeRoot);
|
||||||
|
|
||||||
// Copy authentication only. Copying the whole operator ~/.codex tree leaks
|
// Copy authentication only. Copying the whole operator ~/.codex tree leaks
|
||||||
// plugins, MCP servers, rules, memories, and skills into a supposedly
|
// plugins, MCP servers, rules, memories, and skills into a supposedly
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export function snapshotFixture(directory: string): Record<string, string> {
|
|||||||
|
|
||||||
export interface SourceRequest {
|
export interface SourceRequest {
|
||||||
tool: string; args: string[]; endpoint?: string; method?: string; cwd: string;
|
tool: string; args: string[]; endpoint?: string; method?: string; cwd: string;
|
||||||
violation?: string;
|
env?: Record<string, string>; violation?: string;
|
||||||
pid?: number; ppid?: number; parentExecutable?: string; parentCommand?: string;
|
pid?: number; ppid?: number; parentExecutable?: string; parentCommand?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,6 +463,22 @@ export function sharedReadOnlyViolations(toolCalls: Array<{ tool: string; input:
|
|||||||
return [...new Set(violations)];
|
return [...new Set(violations)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SAFE_GIT_ENV = { GIT_OPTIONAL_LOCKS: '0', GIT_NO_LAZY_FETCH: '1', GIT_TERMINAL_PROMPT: '0' };
|
||||||
|
const SAFE_GIT_FLAGS = ['--no-pager', '--no-lazy-fetch', '--no-replace-objects'];
|
||||||
|
const SAFE_GIT_CONFIG = ['core.fsmonitor=false', 'log.showSignature=false', 'diff.submodule=short'];
|
||||||
|
|
||||||
|
/** A repository Git process that carries the complete safe prefix bin/gstack-safe-git applies, including its
|
||||||
|
* environment, plus patch-driver disabling for diffs; a partial literal prefix does not qualify. */
|
||||||
|
export function isGuardedGitRequest(request: SourceRequest): boolean {
|
||||||
|
const args = request.args, command = args.findIndex((arg, i) => !arg.startsWith('-') && args[i - 1] !== '-c' && args[i - 1] !== '-C');
|
||||||
|
const globals = command < 0 ? args : args.slice(0, command), rest = command < 0 ? [] : args.slice(command);
|
||||||
|
const configs = globals.flatMap((arg, i) => globals[i - 1] === '-c' ? [arg] : []);
|
||||||
|
return Object.entries(SAFE_GIT_ENV).every(([key, value]) => request.env?.[key] === value)
|
||||||
|
&& SAFE_GIT_FLAGS.every(flag => globals.includes(flag)) && SAFE_GIT_CONFIG.every(config => configs.includes(config))
|
||||||
|
&& !rest.some(arg => ['--ext-diff', '--textconv', '--output'].includes(arg) || arg.startsWith('--output='))
|
||||||
|
&& (rest[0] !== 'diff' || (rest.includes('--no-ext-diff') && rest.includes('--no-textconv')));
|
||||||
|
}
|
||||||
|
|
||||||
/** Claude's own workspace probes are not commands requested by the skill. */
|
/** Claude's own workspace probes are not commands requested by the skill. */
|
||||||
export function isInternalClaudeGitRequest(request: SourceRequest, commands: string[]): boolean {
|
export function isInternalClaudeGitRequest(request: SourceRequest, commands: string[]): boolean {
|
||||||
const hostPrefix = ['-c', 'protocol.ext.allow=never', '-c', 'submodule.recurse=false',
|
const hostPrefix = ['-c', 'protocol.ext.allow=never', '-c', 'submodule.recurse=false',
|
||||||
@@ -578,7 +594,8 @@ export function installSourceShims(f: SharedLibsFixture, opts: {
|
|||||||
}
|
}
|
||||||
const common = `const fs=require('node:fs'), cp=require('node:child_process');\nconst a=process.argv.slice(2);\nconst trace=${JSON.stringify(f.trace)};\nconst parent={pid:process.pid,ppid:process.ppid};try{parent.parentExecutable=fs.readlinkSync('/proc/'+process.ppid+'/exe');parent.parentCommand=fs.readFileSync('/proc/'+process.ppid+'/cmdline','utf8').replaceAll('\\0',' ');}catch{try{const info=cp.spawnSync('ps',['-p',String(process.ppid),'-o','comm=','-o','args='],{encoding:'utf8',timeout:3_000});const line=(info.stdout||'').trim();parent.parentExecutable=line.split(/\\s+/)[0];parent.parentCommand=line;}catch{}}\n`;
|
const common = `const fs=require('node:fs'), cp=require('node:child_process');\nconst a=process.argv.slice(2);\nconst trace=${JSON.stringify(f.trace)};\nconst parent={pid:process.pid,ppid:process.ppid};try{parent.parentExecutable=fs.readlinkSync('/proc/'+process.ppid+'/exe');parent.parentCommand=fs.readFileSync('/proc/'+process.ppid+'/cmdline','utf8').replaceAll('\\0',' ');}catch{try{const info=cp.spawnSync('ps',['-p',String(process.ppid),'-o','comm=','-o','args='],{encoding:'utf8',timeout:3_000});const line=(info.stdout||'').trim();parent.parentExecutable=line.split(/\\s+/)[0];parent.parentCommand=line;}catch{}}\n`;
|
||||||
fs.writeFileSync(path.join(f.bin, 'git'), `#!${nodeBin}\n${common}
|
fs.writeFileSync(path.join(f.bin, 'git'), `#!${nodeBin}\n${common}
|
||||||
fs.appendFileSync(trace,JSON.stringify({tool:'git',args:a,cwd:process.cwd(),...parent})+'\\n');
|
const gitEnv=Object.fromEntries(['GIT_OPTIONAL_LOCKS','GIT_NO_LAZY_FETCH','GIT_TERMINAL_PROMPT'].filter(k=>k in process.env).map(k=>[k,process.env[k]]));
|
||||||
|
fs.appendFileSync(trace,JSON.stringify({tool:'git',args:a,env:gitEnv,cwd:process.cwd(),...parent})+'\\n');
|
||||||
if (${!!opts.unsupportedGit} && a.some(x=>x==='--no-lazy-fetch')) { console.error('unknown option: --no-lazy-fetch'); process.exit(129); }
|
if (${!!opts.unsupportedGit} && a.some(x=>x==='--no-lazy-fetch')) { console.error('unknown option: --no-lazy-fetch'); process.exit(129); }
|
||||||
if(a.includes('ls-remote')) { console.log('ref: refs/heads/main\\tHEAD\\n${f.tip}\\tHEAD\\n${f.tip}\\trefs/heads/main'); process.exit(0); }
|
if(a.includes('ls-remote')) { console.log('ref: refs/heads/main\\tHEAD\\n${f.tip}\\tHEAD\\n${f.tip}\\trefs/heads/main'); process.exit(0); }
|
||||||
// The fixture remote is already current. Record fetch attempts without contacting a real repository.
|
// The fixture remote is already current. Record fetch attempts without contacting a real repository.
|
||||||
@@ -758,9 +775,10 @@ export function seedOpportunitySources(f: SharedLibsFixture): void {
|
|||||||
|
|
||||||
export function standaloneInstructions(f: SharedLibsFixture, codex = false): string {
|
export function standaloneInstructions(f: SharedLibsFixture, codex = false): string {
|
||||||
const source = codex ? path.join(SHARED_LIBS_ROOT, '.agents/skills/gstack-deslop-shared-libs') : path.join(SHARED_LIBS_ROOT, 'deslop-shared-libs');
|
const source = codex ? path.join(SHARED_LIBS_ROOT, '.agents/skills/gstack-deslop-shared-libs') : path.join(SHARED_LIBS_ROOT, 'deslop-shared-libs');
|
||||||
|
// Resolve the installed helper to this checkout, as the hermetic runtime for the skill under test.
|
||||||
const text = extractSkillSections(source, [
|
const text = extractSkillSections(source, [
|
||||||
'Scope and read-only boundary', 'Establish the reviewed source', 'Start with recent work', 'Evaluate candidates', 'Output',
|
'Scope and read-only boundary', 'Establish the reviewed source', 'Start with recent work', 'Evaluate candidates', 'Output',
|
||||||
]);
|
]).replaceAll(codex ? '~/.codex/skills/gstack' : '~/.claude/skills/gstack', SHARED_LIBS_ROOT);
|
||||||
const file = path.join(f.root, 'standalone-instructions.md');
|
const file = path.join(f.root, 'standalone-instructions.md');
|
||||||
fs.writeFileSync(file, text);
|
fs.writeFileSync(file, text);
|
||||||
return file;
|
return file;
|
||||||
|
|||||||
@@ -42,14 +42,14 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
|||||||
'shared-libs-review-path-eligibility': ['review/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/review.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-paths.test.ts', 'test/helpers/shared-libs-path-fixture.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/fixtures/shared-libs-resolved-reads-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
'shared-libs-review-path-eligibility': ['review/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/review.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-paths.test.ts', 'test/helpers/shared-libs-path-fixture.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/fixtures/shared-libs-resolved-reads-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
||||||
'shared-libs-review-index-flags': ['review/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/review.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-paths.test.ts', 'test/helpers/shared-libs-path-fixture.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/fixtures/shared-libs-paths-max-turns-public.json', 'test/fixtures/shared-libs-resolved-reads-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
'shared-libs-review-index-flags': ['review/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/review.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-paths.test.ts', 'test/helpers/shared-libs-path-fixture.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/fixtures/shared-libs-paths-max-turns-public.json', 'test/fixtures/shared-libs-resolved-reads-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
||||||
'shared-libs-review-prior-coverage': ['review/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/review.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-paths.test.ts', 'test/helpers/shared-libs-path-fixture.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/fixtures/shared-libs-resolved-reads-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
'shared-libs-review-prior-coverage': ['review/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/review.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-paths.test.ts', 'test/helpers/shared-libs-path-fixture.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/fixtures/shared-libs-resolved-reads-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
||||||
'shared-libs-codex-read-only': ['deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/helpers/codex-session-runner.ts', 'test/helpers/skill-fixture.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/eval-budgets.ts', 'test/codex-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'hosts/codex.ts', 'hosts/define-host.ts', 'scripts/resolvers/constants.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts'],
|
'shared-libs-codex-read-only': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/helpers/codex-session-runner.ts', 'test/helpers/skill-fixture.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/eval-budgets.ts', 'test/codex-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'hosts/codex.ts', 'hosts/define-host.ts', 'scripts/resolvers/constants.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts'],
|
||||||
// Shared-code audit and scoped review lifecycle
|
// Shared-code audit and scoped review lifecycle
|
||||||
'shared-libs-read-only': ['deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/shared-libs-review-start-evidence.ts', 'test/helpers/shared-libs-path-fixture.ts'],
|
'shared-libs-read-only': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/shared-libs-review-start-evidence.ts', 'test/helpers/shared-libs-path-fixture.ts'],
|
||||||
'shared-libs-unsupported-git': ['deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/shared-libs-review-start-evidence.ts', 'test/helpers/shared-libs-path-fixture.ts'],
|
'shared-libs-unsupported-git': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/shared-libs-review-start-evidence.ts', 'test/helpers/shared-libs-path-fixture.ts'],
|
||||||
'shared-libs-review-lifecycle': ['deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'review/**', 'scripts/resolvers/review.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/helpers/shared-libs-path-fixture.ts', 'test/fixtures/shared-libs-lifecycle-r59-stage-scope-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
'shared-libs-review-lifecycle': ['deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'review/**', 'scripts/resolvers/review.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/helpers/shared-libs-path-fixture.ts', 'test/fixtures/shared-libs-lifecycle-r59-stage-scope-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
||||||
'shared-libs-review-revalidation': ['deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'review/**', 'scripts/resolvers/review.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/helpers/shared-libs-review-start-evidence.ts', 'test/fixtures/shared-libs-review-start-public.json', 'test/fixtures/shared-libs-revalidation-max-turns-public.json', 'test/fixtures/shared-libs-index-flags-*.json', 'test/helpers/shared-libs-path-fixture.ts' ],
|
'shared-libs-review-revalidation': ['deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'review/**', 'scripts/resolvers/review.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/helpers/shared-libs-review-start-evidence.ts', 'test/fixtures/shared-libs-review-start-public.json', 'test/fixtures/shared-libs-revalidation-max-turns-public.json', 'test/fixtures/shared-libs-index-flags-*.json', 'test/helpers/shared-libs-path-fixture.ts' ],
|
||||||
'shared-libs-opportunity-judgment': ['deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/shared-libs-plan-actor.ts', 'test/helpers/shared-libs-plan-excerpt.ts'],
|
'shared-libs-opportunity-judgment': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/shared-libs-plan-actor.ts', 'test/helpers/shared-libs-plan-excerpt.ts'],
|
||||||
'shared-libs-pr-coverage': ['deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/shared-libs-plan-actor.ts', 'test/helpers/shared-libs-plan-excerpt.ts'],
|
'shared-libs-pr-coverage': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/shared-libs-plan-actor.ts', 'test/helpers/shared-libs-plan-excerpt.ts'],
|
||||||
'shared-libs-plan-callers': ['test/helpers/shared-libs-plan-actor.ts', 'scripts/resolvers/confidence.ts', 'test/helpers/shared-libs-plan-excerpt.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'plan-eng-review/**', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/fixtures/plan-scope-recovery-av.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts'],
|
'shared-libs-plan-callers': ['test/helpers/shared-libs-plan-actor.ts', 'scripts/resolvers/confidence.ts', 'test/helpers/shared-libs-plan-excerpt.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'plan-eng-review/**', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/fixtures/plan-scope-recovery-av.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts'],
|
||||||
'ship-docsync-missing-marker': ['ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts'],
|
'ship-docsync-missing-marker': ['ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts'],
|
||||||
'ship-docsync-missing-asset': ['ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts'],
|
'ship-docsync-missing-asset': ['ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts'],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import * as path from 'node:path';
|
|||||||
import { execFileSync, spawnSync } from 'node:child_process';
|
import { execFileSync, spawnSync } from 'node:child_process';
|
||||||
import {
|
import {
|
||||||
createSharedInteractiveToolHandler, createSharedLibsFixture, fixtureGit, fixtureWrite, installSourceShims,
|
createSharedInteractiveToolHandler, createSharedLibsFixture, fixtureGit, fixtureWrite, installSourceShims,
|
||||||
readRequests, seedOpportunitySources, sharedReadOnlyViolations, shellQuote, snapshotFixture, type SharedLibsFixture,
|
installHostileGitConfig, isGuardedGitRequest, standaloneInstructions, SHARED_LIBS_ROOT, readRequests, seedOpportunitySources, sharedReadOnlyViolations, shellQuote, snapshotFixture, type SharedLibsFixture,
|
||||||
SharedCaptureAccumulator, type SharedCaptureAttempt, isInternalClaudeGitRequest, SHARED_LIBS_OLDER_OPEN_PRS, incompleteFirstFileView,
|
SharedCaptureAccumulator, type SharedCaptureAttempt, isInternalClaudeGitRequest, SHARED_LIBS_OLDER_OPEN_PRS, incompleteFirstFileView,
|
||||||
} from './helpers/shared-libs-eval-fixture';
|
} from './helpers/shared-libs-eval-fixture';
|
||||||
import { EvalCollector, type EvalTestEntry } from './helpers/eval-store';
|
import { EvalCollector, type EvalTestEntry } from './helpers/eval-store';
|
||||||
@@ -27,6 +27,51 @@ function scratch(): string {
|
|||||||
return directory;
|
return directory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('shared-code Git guard', () => {
|
||||||
|
test('the standalone runtime resolves the real helper, and only its complete prefix counts as a guarded read', () => {
|
||||||
|
const f = createSharedLibsFixture('safe-git');
|
||||||
|
cleanup.push(f.root);
|
||||||
|
seedOpportunitySources(f);
|
||||||
|
installHostileGitConfig(f);
|
||||||
|
installSourceShims(f);
|
||||||
|
const instructions = fs.readFileSync(standaloneInstructions(f), 'utf8');
|
||||||
|
const helper = path.join(SHARED_LIBS_ROOT, 'bin/gstack-safe-git');
|
||||||
|
expect(instructions).toContain(`\`${helper} rev-parse --is-inside-work-tree\``);
|
||||||
|
expect(instructions).not.toContain('~/.claude/skills/gstack');
|
||||||
|
const run = (command: string, args: string[]) => spawnSync(command, args, {
|
||||||
|
cwd: f.repo, encoding: 'utf8', timeout: 10_000, env: { ...process.env, ...f.env } });
|
||||||
|
const refusal = run(helper, ['status']);
|
||||||
|
expect(refusal.status).toBe(2);
|
||||||
|
expect(refusal.stderr).toContain('gstack-safe-git: refused');
|
||||||
|
// Exit status is Git's own; with Git < 2.44 these reach the recording shim and then fail on --no-lazy-fetch.
|
||||||
|
for (const args of [['rev-parse', '--is-inside-work-tree'], ['log', '-p', '-1'],
|
||||||
|
['diff', f.tip, f.tip, '--', 'README.md'], ['ls-files', '--cached', '--others', '--exclude-standard', '-z']]) run(helper, args);
|
||||||
|
const guarded = readRequests(f).filter(row => row.tool === 'git');
|
||||||
|
expect(guarded.map(row => row.args[9])).toEqual(['rev-parse', 'log', 'diff', 'ls-files']);
|
||||||
|
for (const row of guarded) expect(isGuardedGitRequest(row), JSON.stringify(row)).toBe(true);
|
||||||
|
expect(fs.existsSync(f.hookTrace) ? fs.readFileSync(f.hookTrace, 'utf8') : '').toBe('');
|
||||||
|
|
||||||
|
fs.rmSync(f.trace, { force: true });
|
||||||
|
run('git', ['log', '-1']);
|
||||||
|
run('git', ['--no-lazy-fetch', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', 'rev-parse', 'HEAD']);
|
||||||
|
const unguarded = readRequests(f);
|
||||||
|
expect(unguarded).toHaveLength(2);
|
||||||
|
for (const row of unguarded) expect(isGuardedGitRequest(row), JSON.stringify(row)).toBe(false);
|
||||||
|
|
||||||
|
const wrapped = guarded[2]!;
|
||||||
|
const without = (value: string) => ({ ...wrapped, args: wrapped.args.filter(arg => arg !== value) });
|
||||||
|
for (const damaged of [
|
||||||
|
{ ...wrapped, env: {} },
|
||||||
|
{ ...wrapped, env: { ...wrapped.env, GIT_NO_LAZY_FETCH: '0' } },
|
||||||
|
without('--no-replace-objects'), without('--no-pager'), without('diff.submodule=short'),
|
||||||
|
without('--no-textconv'), without('--no-ext-diff'),
|
||||||
|
{ ...wrapped, args: [...wrapped.args, '--ext-diff'] },
|
||||||
|
{ ...wrapped, args: [...wrapped.args, '--output=out.patch'] },
|
||||||
|
{ ...wrapped, args: wrapped.args.map(arg => arg === 'core.fsmonitor=false' ? 'core.fsmonitor=true' : arg) },
|
||||||
|
]) expect(isGuardedGitRequest(damaged), JSON.stringify(damaged.args)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('shared-code legacy interactive actor', () => {
|
describe('shared-code legacy interactive actor', () => {
|
||||||
test('R58 acknowledges the exact removed-filter Skip packet without authorizing its recommended fix', async () => {
|
test('R58 acknowledges the exact removed-filter Skip packet without authorizing its recommended fix', async () => {
|
||||||
const input = {
|
const input = {
|
||||||
|
|||||||
@@ -54,8 +54,14 @@ describe('shared-code skill distribution', () => {
|
|||||||
expect(standalone).toContain('including repeated page numbers');
|
expect(standalone).toContain('including repeated page numbers');
|
||||||
expect(standalone).toContain('temporary files and files outside the repository');
|
expect(standalone).toContain('temporary files and files outside the repository');
|
||||||
expect(standalone).toContain('Keep API responses and intermediate data on stdout or in memory');
|
expect(standalone).toContain('Keep API responses and intermediate data on stdout or in memory');
|
||||||
expect(standalone).toContain('--no-lazy-fetch');
|
// Git safety is the installed helper from the trusted global runtime, not a retyped prefix.
|
||||||
expect(standalone).toContain('log.showSignature=false');
|
const safeGit = `~/${host.globalRoot}/bin/gstack-safe-git`;
|
||||||
|
expect(standalone).toContain(`${safeGit} rev-parse --is-inside-work-tree`);
|
||||||
|
expect(standalone).toContain(`${safeGit} ls-files --cached --others --exclude-standard -z`);
|
||||||
|
expect(standalone).toContain('never bare `git`');
|
||||||
|
expect(standalone).not.toContain('git --no-pager');
|
||||||
|
expect(standalone).not.toContain('$GSTACK_ROOT');
|
||||||
|
expect(standalone).not.toContain('{{SAFE_GIT}}');
|
||||||
expect(standalone).toContain('python3 -I -S');
|
expect(standalone).toContain('python3 -I -S');
|
||||||
expect(standalone).toContain('two explicit committed object IDs');
|
expect(standalone).toContain('two explicit committed object IDs');
|
||||||
expect(standalone).toContain('same Git tree');
|
expect(standalone).toContain('same Git tree');
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { EvalCollector } from './helpers/eval-store';
|
|||||||
import {
|
import {
|
||||||
addBranchAndRawOverlay, createSharedLibsFixture, fixtureGit, fixtureWrite, fixtureWorkingTree,
|
addBranchAndRawOverlay, createSharedLibsFixture, fixtureGit, fixtureWrite, fixtureWorkingTree,
|
||||||
installHostileGitConfig, installInterpreterCanary, installNormalizingFilter,
|
installHostileGitConfig, installInterpreterCanary, installNormalizingFilter,
|
||||||
isInternalClaudeGitRequest,
|
isGuardedGitRequest, isInternalClaudeGitRequest,
|
||||||
installSourceShims, readRequests, reviewLifecycleInstructions, reviewPrompt, reviewRevalidationPrompt,
|
installSourceShims, readRequests, reviewLifecycleInstructions, reviewPrompt, reviewRevalidationPrompt,
|
||||||
reviewRecords, runSharedCapture, runSharedInteractive, seedOpportunitySources,
|
reviewRecords, runSharedCapture, runSharedInteractive, seedOpportunitySources,
|
||||||
seedReviewSources, seedSkippedAdvisory, snapshotFixture, specialistFixture,
|
seedReviewSources, seedSkippedAdvisory, snapshotFixture, specialistFixture,
|
||||||
@@ -88,9 +88,8 @@ function assertReadOnly(f: SharedLibsFixture, before: Record<string, string>, re
|
|||||||
expect(request.args).not.toContain('add');
|
expect(request.args).not.toContain('add');
|
||||||
expect(request.args).not.toContain('write-tree');
|
expect(request.args).not.toContain('write-tree');
|
||||||
expect(request.args).not.toContain('hash-object');
|
expect(request.args).not.toContain('hash-object');
|
||||||
expect(request.args).toContain('--no-lazy-fetch');
|
// Every repository read carries the complete gstack-safe-git prefix, environment included.
|
||||||
expect(request.args).toContain('core.fsmonitor=false');
|
expect(isGuardedGitRequest(request), JSON.stringify(request)).toBe(true);
|
||||||
expect(request.args).toContain('log.showSignature=false');
|
|
||||||
}
|
}
|
||||||
for (const request of requests.filter(row => row.tool === 'gh' && row.args[0] === 'api')) {
|
for (const request of requests.filter(row => row.tool === 'gh' && row.args[0] === 'api')) {
|
||||||
expect(request.method).toBe('GET');
|
expect(request.method).toBe('GET');
|
||||||
|
|||||||
Reference in new issue
Block a user