Skip to content

Commit 7369563

Browse files
feat: execution sandbox hardening (Codex #1, Level 1) + remove internal docs (#10)
Sandbox (the last open critical from the Codex review): - createCommandRunner({ env }) — when set, replaces the child env so spawned commands don't inherit the parent environment. - scrubbedEnv() strips secret-shaped vars (ANTHROPIC_API_KEY, OPENAI_API_KEY, GH/GITHUB tokens, *_KEY/_TOKEN/_SECRET/_PASSWORD, AWS_* etc.) while keeping PATH/HOME/npm_config_* so pnpm/tsc/vitest still run. - run-a builds the loop's runner with scrubbedEnv(); GitOps keeps its own full-env runner so `git push` / `gh pr create` still authenticate. That's credential separation: secrets are in scope ONLY for the git/gh steps that need them, never for untrusted target-repo install/build/test. - pnpm install runs with --ignore-scripts by default; opt back in via ASIL_ALLOW_INSTALL_SCRIPTS=1. Kills lifecycle-script RCE on install. - README gains a "Running against untrusted repos" section stating plainly what Level 1 does and does not protect (no fs/network isolation yet; containerized exec is the planned opt-in for adversarial input). Internal docs removed from the public repo and moved to the private KB (ASIL/private-kb/, outside the repo): CODEX_REVIEW.md and the design doc. .gitignore now excludes CODEX_REVIEW.md and docs/design/ so they can't be re-committed by accident. Tests: 384 → 387. New: scrubbedEnv strips secrets / keeps build vars; CommandRunner { env } isolates a secret from the spawned process while the inherited runner still sees it; loop installs with --ignore-scripts by default. No skips. Refs Codex review #1 (private KB).
1 parent 6eef546 commit 7369563

9 files changed

Lines changed: 186 additions & 164 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ dist/
1515
.asil/
1616
*.log
1717
coverage/
18+
19+
# Internal/private review + design notes — kept in private KB, never in the public repo
20+
CODEX_REVIEW.md
21+
docs/design/

CODEX_REVIEW.md

Lines changed: 0 additions & 69 deletions
This file was deleted.

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,15 @@ Optional layer. Pass `--transcripts <dir>` to `pnpm --filter asil-runners run:a`
8686

8787
ASIL can run against any HTTP server speaking the OpenAI-compatible `/v1/chat/completions` API: **Ollama, LM Studio, vLLM, llama.cpp server, OpenRouter, Azure OpenAI**. Set `ASIL_LLM_BASE_URL` and `ASIL_LLM_MODEL` and the runner uses the local adapter instead of cloud Anthropic. `ANTHROPIC_API_KEY` is no longer required in local mode. Cost-controller token caps still bite (chars/4 fallback when the server omits `usage`); the dollar number reports as $0 since local inference has no wire cost. Full walkthrough — including the mixed cloud-execution + local-adversarial-gate recipe — in [`examples/local-llm.md`](examples/local-llm.md).
8888

89+
### Running against untrusted repos — sandbox
90+
91+
ASIL executes target-repo code (`pnpm install`, build, tests) inside disposable worktrees. To keep that safe:
92+
93+
- **Secrets are stripped from the execution environment.** The runner that executes target-repo commands gets a scrubbed env — `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and git/GitHub tokens are never in scope, so a malicious `postinstall`/build/test script can't exfiltrate them. The git/`gh` operations that genuinely need a token use a separate, full-env runner (credential separation).
94+
- **Install lifecycle scripts are disabled by default** (`pnpm install --ignore-scripts`). Opt back in with `ASIL_ALLOW_INSTALL_SCRIPTS=1` only for repos you trust.
95+
96+
This is process-level hardening (Level 1). It removes the credential-exfiltration vector but does **not** sandbox the filesystem or network — a hostile repo's build/test can still read local files or make network calls. For genuinely adversarial input, run ASIL inside a container with `--network=none` (containerized exec is a planned opt-in). See the design notes in the private KB.
97+
8998
### Languages — Python profile
9099

91100
The scanner is profile-driven (`LanguageProfile` interface). TypeScript is the reference; Python ships out of the box. Select via `--profile <ts|python>`. Python requires `pytest` with the `pytest-json-report` plugin, `mypy`, and `coverage.py`. Adding Go or Rust is a "write a profile" task, not a fork.

docs/design/2026-06-17-criticals-sandbox-and-budget.md

Lines changed: 0 additions & 87 deletions
This file was deleted.

packages/asil-improvement-loop/src/__tests__/loop.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,42 @@ describe('runLoop — integration', () => {
149149
expect(result.outcomes[0]?.totalTokenUsage.outputTokens).toBeGreaterThan(100);
150150
});
151151

152+
it('installs target-repo deps with --ignore-scripts by default (Codex #1 — no lifecycle-script RCE)', async () => {
153+
const queue = new TaskQueue(queuePath);
154+
queue.enqueue(mkTask({ id: 't-ignore-scripts' }));
155+
156+
const installArgsSeen: string[][] = [];
157+
const runner = mockRunner([
158+
{
159+
match: (cmd, args) => {
160+
if (cmd === 'pnpm' && args.includes('install')) installArgsSeen.push(args);
161+
return cmd === 'pnpm' && args.includes('install');
162+
},
163+
exitCode: 0,
164+
},
165+
{ match: (cmd) => cmd === 'diff', exitCode: 1, stdout: CANNED_UNIFIED_DIFF },
166+
{ match: (cmd) => cmd === 'pnpm', exitCode: 0 },
167+
{ match: (cmd) => cmd === 'grep', exitCode: 0 },
168+
]);
169+
170+
await runLoop(cfg(), {
171+
llm: goodLLM(),
172+
codex: mockCodex(JSON.stringify({ approved: true, severity: 'pass' })),
173+
git: mockGit('https://example.com/pr/3'),
174+
tracker,
175+
budgetManager,
176+
runner,
177+
fileReader: mockFileReader(),
178+
fileFetcher: mockFileFetcher(),
179+
diff: mockDiffApplier(),
180+
readCurrent: fakeReadCurrent,
181+
queue,
182+
});
183+
184+
expect(installArgsSeen.length).toBeGreaterThan(0);
185+
expect(installArgsSeen[0]).toContain('--ignore-scripts');
186+
});
187+
152188
it('pnpm install failure in the worktree → task aborts as infra-failed, LLM never called, worktree cleaned (Codex #6)', async () => {
153189
const queue = new TaskQueue(queuePath);
154190
queue.enqueue(mkTask({ id: 't-install-fail' }));

packages/asil-improvement-loop/src/loop.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,11 +221,18 @@ export async function runLoop(
221221
// 3b. Install dependencies in the worktree so typecheck + tests work.
222222
// Without this the clone has no node_modules and every tsc/vitest
223223
// call fails with "Cannot find module" errors.
224-
const installResult = await deps.runner.run(
225-
'pnpm',
226-
['install', '--frozen-lockfile'],
227-
{ cwd: workDir },
228-
);
224+
// --ignore-scripts by default: a target repo's install lifecycle
225+
// scripts (postinstall/prepare) are arbitrary code we should not
226+
// run with ASIL's privileges. Opt back in with
227+
// ASIL_ALLOW_INSTALL_SCRIPTS=1 for repos that genuinely need them
228+
// (the operator then explicitly accepts the risk). (Codex #1.)
229+
const allowInstallScripts = process.env.ASIL_ALLOW_INSTALL_SCRIPTS === '1';
230+
const installArgs = allowInstallScripts
231+
? ['install', '--frozen-lockfile']
232+
: ['install', '--frozen-lockfile', '--ignore-scripts'];
233+
const installResult = await deps.runner.run('pnpm', installArgs, {
234+
cwd: workDir,
235+
});
229236
if (installResult.exitCode !== 0) {
230237
// Install failure is fatal: with no node_modules, every
231238
// downstream typecheck/test runs in a known-bad environment and

0 commit comments

Comments
 (0)