@clear-capabilities/agentic-security-scanner 0.163.1 → 0.165.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/9560.index.js +24 -10
- package/dist/agentic-security.mjs +1 -1
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -4
- package/src/capabilities/CLAUDE.md +20 -13
- package/src/capabilities/attack-coverage.js +1 -1
- package/src/capabilities/manifest.js +2 -2
- package/src/capabilities/probes.js +22 -8
- package/src/capabilities/report.js +5 -4
- package/src/capabilities/runner.js +6 -1
- package/src/sandbox/CLAUDE.md +61 -18
- package/src/sandbox/backend-namespace.js +246 -117
- package/src/sandbox/capabilities.js +22 -0
- package/src/sandbox/control-probes.js +91 -7
- package/src/sandbox/limits.js +2 -0
- package/src/sandbox/linux-probes.js +179 -0
- package/src/sandbox/linux-rootfs.js +263 -0
- package/src/sandbox/supervise.js +38 -15
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
5a4520f3bff6f1e77c0bb2bfaa0346b11c1447eb3a8433bc1661d2f58c03537a agentic-security.mjs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clear-capabilities/agentic-security-scanner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.165.0",
|
|
4
4
|
"description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"test:extras": "AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js && node --experimental-test-module-mocks --test test/fault-injection.test.js && node --experimental-test-module-mocks --test test/detector-fault-injection.test.js && node --experimental-test-module-mocks --test test/detector-fault-injection-k8s-admission.test.js && node --experimental-test-module-mocks --test test/lineage-fault-injection.test.js && npm run test:python",
|
|
69
69
|
"loop": "node ../scripts/loop-engineering/run.mjs",
|
|
70
70
|
"test:loop-real-prd": "node --test ../scripts/loop-engineering/test/loop-real-prd.test.js",
|
|
71
|
-
"test:loop": "node --test --test-concurrency=1 ../scripts/loop-engineering/test/loop-manifest.test.js ../scripts/loop-engineering/test/loop-watchdog.test.js ../scripts/loop-engineering/test/loop-background.test.js ../scripts/loop-engineering/test/loop-dashboard.test.js ../scripts/loop-engineering/test/loop-evidence.test.js ../scripts/loop-engineering/test/loop-recovery.test.js ../scripts/loop-engineering/test/loop-remote.test.js ../scripts/loop-engineering/test/loop-runbook.test.js ../scripts/loop-engineering/test/loop-assurance-profile.test.js ../scripts/loop-engineering/test/loop-assurance-runtime.test.js",
|
|
71
|
+
"test:loop": "node --test --test-concurrency=1 ../scripts/loop-engineering/test/loop-manifest.test.js ../scripts/loop-engineering/test/loop-watchdog.test.js ../scripts/loop-engineering/test/loop-background.test.js ../scripts/loop-engineering/test/loop-dashboard.test.js ../scripts/loop-engineering/test/loop-evidence.test.js ../scripts/loop-engineering/test/loop-recovery.test.js ../scripts/loop-engineering/test/loop-remote.test.js ../scripts/loop-engineering/test/loop-runbook.test.js ../scripts/loop-engineering/test/loop-assurance-profile.test.js ../scripts/loop-engineering/test/loop-assurance-runtime.test.js ../scripts/loop-engineering/test/loop-bounds.test.js ../scripts/loop-engineering/test/loop-spend.test.js",
|
|
72
72
|
"test:python": "node ../scripts/run-python-tests.mjs",
|
|
73
73
|
"test:smoke": "node --test test/smoke.test.js test/demo-app.test.js",
|
|
74
74
|
"test:ollama-e2e": "AGENTIC_SECURITY_OLLAMA_E2E=1 node --test test/ollama-e2e.test.js",
|
|
@@ -106,11 +106,11 @@
|
|
|
106
106
|
"evaluation": "node ../scripts/evaluation.mjs",
|
|
107
107
|
"evaluation:synthetic": "node ../scripts/evaluation.mjs synthetic",
|
|
108
108
|
"test:routing": "node --test test/routing/outcomes.test.js test/routing/economics.test.js test/routing/adapter.test.js test/routing/calibration.test.js test/routing/decide.test.js test/routing/unchanged-when-off.test.js test/routing/shadow.test.js test/routing/promotion.test.js test/routing/drift.test.js test/routing/canary.test.js test/routing/feedback.test.js test/routing/report.test.js",
|
|
109
|
-
"test:release-closure": "node --test test/release-closure/closure-plan.test.js test/release-closure/closure-record.test.js test/release-closure/compatibility.test.js test/release-closure/rollout-stages.test.js test/release-closure/migration-readers.test.js test/release-closure/rollback.test.js test/release-closure/wrappers.test.js test/release-closure/final-closure.test.js test/release-closure/final-closure-controller.test.js test/release-closure/runner-hardening.test.js",
|
|
109
|
+
"test:release-closure": "node --test test/release-closure/closure-plan.test.js test/release-closure/closure-record.test.js test/release-closure/compatibility.test.js test/release-closure/rollout-stages.test.js test/release-closure/migration-readers.test.js test/release-closure/rollback.test.js test/release-closure/wrappers.test.js test/release-closure/final-closure.test.js test/release-closure/final-closure-controller.test.js test/release-closure/runner-hardening.test.js test/release-closure/remote-attestation.test.js",
|
|
110
110
|
"release:closure:check": "node ../scripts/release-closure.mjs --run",
|
|
111
111
|
"release:closure:static": "node ../scripts/release-closure.mjs --static",
|
|
112
112
|
"test:documentation": "node --test test/documentation/drift-check.test.js test/documentation/generated-pages.test.js test/documentation/scope-and-examples.test.js test/documentation/policy-cards.test.js test/documentation/operations-and-review.test.js",
|
|
113
|
-
"test:capabilities": "node --test test/capabilities/manifest.test.js test/capabilities/filesystem.test.js test/capabilities/exec.test.js test/capabilities/network.test.js test/capabilities/canary.test.js test/capabilities/tools.test.js test/capabilities/recovery.test.js test/capabilities/receipts.test.js test/capabilities/adversarial.test.js",
|
|
113
|
+
"test:capabilities": "node --test test/capabilities/manifest.test.js test/capabilities/filesystem.test.js test/capabilities/exec.test.js test/capabilities/network.test.js test/capabilities/canary.test.js test/capabilities/tools.test.js test/capabilities/recovery.test.js test/capabilities/receipts.test.js test/capabilities/adversarial.test.js test/capabilities/linux-enforcement.test.js",
|
|
114
114
|
"test:verification": "node --test test/verification/verification-record.test.js test/verification/oracle-adapters.test.js test/verification/replay-manifest.test.js test/verification/patch-negative.test.js test/verification/non-taint-classes.test.js test/verification/interface-equivalence.test.js test/verification/advisory-gating.test.js test/verification/oracle-conformance.test.js",
|
|
115
115
|
"test:deployment": "node --test test/deployment/boundary-graph.test.js test/deployment/config-adapters.test.js test/deployment/trace-correlation.test.js test/deployment/cross-repo.test.js test/deployment/attack-paths.test.js test/deployment/drift.test.js test/deployment/boundary-presentation.test.js test/deployment/differentiation-ablation.test.js test/deployment/boundaries-cli.test.js",
|
|
116
116
|
"test:portfolio": "node --test test/portfolio/manifest.test.js test/portfolio/bundle.test.js test/portfolio/signing.test.js test/portfolio/work-units.test.js test/portfolio/resume.test.js test/portfolio/scheduler.test.js test/portfolio/progress.test.js test/portfolio/retention.test.js test/portfolio/backend.test.js",
|
|
@@ -67,15 +67,25 @@ report says so. Without the opt-in an isolation-required task is `unsupported`
|
|
|
67
67
|
(`platform-unsupported`). A control that is not proved BLOCKS the run (typed
|
|
68
68
|
`blocked`, `missing-execution-backend`, the target never executes).
|
|
69
69
|
|
|
70
|
-
**Linux is `
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
70
|
+
**Linux is `partially-verified`**, and only by the hosted `sandbox-linux` job. Its
|
|
71
|
+
active probes proved write confinement, read denial, environment scrubbing, the
|
|
72
|
+
no-network default, tree termination, the file-size limit, read confinement to
|
|
73
|
+
declared roots and multi-root writes on the namespace backend (a pivoted tmpfs
|
|
74
|
+
root; protected paths absent or masked; a PID namespace the kernel tears down).
|
|
75
|
+
NOT verified: **mediated network** is not implemented on Linux (an empty network
|
|
76
|
+
namespace has no path to a proxy), so a task that declares a destination is
|
|
77
|
+
`blocked` there with `network-mediation is unsupported`, never allowed. The
|
|
78
|
+
**process-count cap** IS proved on Linux (the `sandbox-linux` job's `process-cap`
|
|
79
|
+
probe): the resource prelude runs under dash, whose `ulimit` has no `-u`, so the
|
|
80
|
+
namespace backend applies the cap with `prlimit --nproc` to the task's command
|
|
81
|
+
after the confinement is built, and `maxProcesses` in a manifest is passed through
|
|
82
|
+
and reported `enforced` when the run is at the `enforced` level (on macOS it stays
|
|
83
|
+
`unverified`: the cap is per-user and system-wide there). Memory caps are
|
|
84
|
+
`not-enforced`. The capability, trust-boundary and corpus suites RUN on the Linux
|
|
85
|
+
backend and assert Linux semantics (an undeclared path has no name, so a refusal
|
|
86
|
+
is ENOENT or EROFS rather than `denied`; liveness is judged by heartbeat, not host
|
|
87
|
+
pid; a declared network destination blocks the task); `sandbox-linux` fails if any
|
|
88
|
+
of them skips.
|
|
79
89
|
|
|
80
90
|
## Known limits (also in `REPORT_LIMITATIONS`)
|
|
81
91
|
|
|
@@ -107,8 +117,5 @@ attempts were stopped on this backend in this run; it does not show the sandbox
|
|
|
107
117
|
### What is still not claimed
|
|
108
118
|
|
|
109
119
|
Tool and delegation checks are policy at the tool boundary (the tool then runs in the server process): the
|
|
110
|
-
runner remains the only enforcement layer. Linux
|
|
111
|
-
record, and no Linux outcome is asserted. No process-count cap is asserted. The corpus is not wired into the
|
|
112
|
-
`sandbox-linux` CI job: it would need execution cases that skip loudly (never silently pass) on Linux, and that
|
|
113
|
-
has not been shown from this workspace. The enforced-mode release gate (`enforcedModeReleaseGate`) is a library
|
|
120
|
+
runner remains the only enforcement layer. Linux is `partially-verified` in the platform statement (the controls listed there, on the hosted job only) and the attack-coverage record keeps `platforms.linux: 'unverified'` as a standing statement even though the corpus now runs on the hosted Linux runner inside the capabilities suite: turning that into a verified claim is a decision for the owners, not a side effect of the suite passing. The enforced-mode release gate (`enforcedModeReleaseGate`) is a library
|
|
114
121
|
function used by the suite; it is not yet called from `scripts/release-check.mjs`.
|
|
@@ -34,7 +34,7 @@ export const KNOWN_LIMITS = Object.freeze([
|
|
|
34
34
|
'HTTPS to a declared destination is an opaque tunnel: its payload is not inspected.',
|
|
35
35
|
'The mediation proxy accepts connections from any local process, and forwards only to the declared destinations.',
|
|
36
36
|
'Tool and delegation checks are policy at the tool boundary (in-process); only the runner enforces.',
|
|
37
|
-
'
|
|
37
|
+
'A process-count cap is claimed only on the Linux namespace backend, where an active probe proved it on the hosted job; it is not claimed on macOS.',
|
|
38
38
|
]);
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -44,8 +44,8 @@ const MANIFEST_REQUIRED = ['schema', 'schemaVersion', 'taskId', 'repository', 'p
|
|
|
44
44
|
|
|
45
45
|
// Resource limits a manifest can carry. The first three come straight from the
|
|
46
46
|
// assurance config (same ranges), the file-size cap is enforced by the sandbox
|
|
47
|
-
// prelude,
|
|
48
|
-
//
|
|
47
|
+
// prelude, the process-count cap is enforced on the Linux namespace backend only (prlimit, proved by an
|
|
48
|
+
// active probe), and the memory cap is CARRIED but never claimed enforced.
|
|
49
49
|
export const RESOURCE_RANGES = Object.freeze({
|
|
50
50
|
timeoutMs: { min: LIMITS.timeoutMs.min, max: LIMITS.timeoutMs.max },
|
|
51
51
|
maxOutputBytes: { min: LIMITS.maxOutputBytes.min, max: LIMITS.maxOutputBytes.max },
|
|
@@ -48,15 +48,18 @@ function mk(prefix) { return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(
|
|
|
48
48
|
function rm(p) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
49
49
|
const token = (p) => `${p}-${crypto.randomBytes(8).toString('hex')}`;
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
// `run` is a seam so a test can hand the probe a runner that is deliberately
|
|
52
|
+
// wrong (a read root left too wide, a root left writable) and see the probe say
|
|
53
|
+
// so: a probe that cannot fail proves nothing.
|
|
54
|
+
export async function probeFsRead(backend, runner = runConfinedSupervised) {
|
|
55
|
+
if (backend !== 'userspace' && backend !== 'namespace') return unsupported(`read confinement to declared roots is not implemented on the ${backend} backend`);
|
|
53
56
|
const root = mk('agsec-cap-r-'); const ro = mk('agsec-cap-ro-'); const outside = mk('agsec-cap-out-');
|
|
54
57
|
try {
|
|
55
58
|
const okTok = token('READABLE'); const secTok = token('SEALED');
|
|
56
59
|
fs.writeFileSync(path.join(ro, 'r.txt'), okTok);
|
|
57
60
|
fs.writeFileSync(path.join(outside, 's.txt'), secTok);
|
|
58
61
|
fs.symlinkSync(path.join(outside, 's.txt'), path.join(ro, 'link'));
|
|
59
|
-
const run = (script, readRoots) =>
|
|
62
|
+
const run = (script, readRoots) => runner(['/bin/sh', '-c', script], { root, readRoots, timeoutMs: 8000, graceMs: 300 });
|
|
60
63
|
const pos = await run(`cat '${ro}/r.txt'`, [ro]);
|
|
61
64
|
if (!pos.stdout.includes(okTok)) return notProved(`positive control failed: a file inside a declared root was not readable (${pos.status})`);
|
|
62
65
|
const open = await run(`cat '${outside}/s.txt'`, [ro, outside]);
|
|
@@ -75,13 +78,13 @@ async function probeFsRead(backend) {
|
|
|
75
78
|
} finally { rm(root); rm(ro); rm(outside); }
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
async function probeMultiWrite(backend) {
|
|
79
|
-
if (backend !== 'userspace') return unsupported(`several declared write roots are not implemented on the ${backend} backend`);
|
|
81
|
+
export async function probeMultiWrite(backend, runner = runConfinedSupervised) {
|
|
82
|
+
if (backend !== 'userspace' && backend !== 'namespace') return unsupported(`several declared write roots are not implemented on the ${backend} backend`);
|
|
80
83
|
const root = mk('agsec-cap-w-'); const w1 = mk('agsec-cap-w1-'); const w2 = mk('agsec-cap-w2-');
|
|
81
84
|
const ro = mk('agsec-cap-wro-'); const outside = mk('agsec-cap-wout-');
|
|
82
85
|
try {
|
|
83
86
|
const script = [`echo a > '${w1}/f'`, `echo b > '${w2}/f'`, `echo c > '${ro}/f'`, `echo d > '${outside}/f'`, 'true'].join('; ');
|
|
84
|
-
await
|
|
87
|
+
await runner(['/bin/sh', '-c', script], { root, readRoots: [ro], writeRoots: [w1, w2], timeoutMs: 8000, graceMs: 300 });
|
|
85
88
|
if (!fs.existsSync(path.join(w1, 'f')) || !fs.existsSync(path.join(w2, 'f'))) return notProved('positive control failed: a declared write root was not writable');
|
|
86
89
|
if (fs.existsSync(path.join(ro, 'f'))) return notProved('a root declared read-only was writable');
|
|
87
90
|
if (fs.existsSync(path.join(outside, 'f'))) return notProved('an undeclared directory was writable');
|
|
@@ -197,8 +200,19 @@ export function isAdvertisedBackend(backend, platform = process.platform) {
|
|
|
197
200
|
export function platformStatements() {
|
|
198
201
|
return Object.freeze({
|
|
199
202
|
linux: {
|
|
200
|
-
backend: 'namespace', status: '
|
|
201
|
-
|
|
203
|
+
backend: 'namespace', status: 'partially-verified',
|
|
204
|
+
// Evidence is the hosted sandbox-linux CI job (ubuntu-latest, x86_64), whose active probes
|
|
205
|
+
// (attack plus positive control, with fault-injection counterparts) proved these controls.
|
|
206
|
+
// Anything not listed here is not claimed: a verified control is verified on that runner image
|
|
207
|
+
// and kernel, not on every Linux host, which is why the job runs on every push.
|
|
208
|
+
evidence: 'sandbox-linux CI job (hosted ubuntu-latest, x86_64); scripts/sandbox-linux-verify.mjs',
|
|
209
|
+
verifiedControls: Object.freeze([
|
|
210
|
+
'write-confinement', 'read-denial', 'env-scrub', 'network', 'tree-termination', 'file-size-limit',
|
|
211
|
+
'fs-read-confinement', 'fs-multi-root-write', 'process-cap',
|
|
212
|
+
]),
|
|
213
|
+
unsupportedControls: Object.freeze(['network-mediation']),
|
|
214
|
+
unassertedControls: Object.freeze([]),
|
|
215
|
+
note: 'advertised for enforced mode. Proved by active probes on the hosted Linux runner (not on every Linux host): write-confinement, read-denial, env-scrub, network, tree-termination, file-size-limit, fs-read-confinement, fs-multi-root-write, process-cap (a per-user-namespace process-count cap applied with prlimit after the confinement is built; the shell ulimit it replaces does not exist in dash). Mediated network is NOT implemented on the namespace backend (an empty network namespace has no path to a proxy), so a task that declares a network destination is blocked on Linux, never allowed. Process-count caps are claimed on Linux only, and only as that probe shows them; memory caps are not enforced. The attack corpus is not run in the sandbox-linux job, so corpus coverage stays unverified on Linux.',
|
|
202
216
|
},
|
|
203
217
|
darwin: {
|
|
204
218
|
backend: 'userspace', status: 'host-proved-not-advertised',
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
//
|
|
14
14
|
// A capability that only an in-process check covers (tools, delegation) says so
|
|
15
15
|
// and is never `enforced`; that check explains a decision, it does not stop the
|
|
16
|
-
// task.
|
|
17
|
-
// backend
|
|
16
|
+
// task. A process-count cap is `enforced` only on a run whose process-cap probe
|
|
17
|
+
// proved it (the Linux namespace backend); on the macOS backend it is carried and
|
|
18
|
+
// reported `unverified`.
|
|
18
19
|
import { platformStatements } from './probes.js';
|
|
19
20
|
|
|
20
21
|
const FS_READ = ['fs-read-confinement', 'read-denial'];
|
|
@@ -28,7 +29,7 @@ export const REPORT_LIMITATIONS = Object.freeze([
|
|
|
28
29
|
'HTTPS is an opaque tunnel to a declared destination: its payload is not inspected. Plaintext HTTP is filtered for secrets before it is forwarded.',
|
|
29
30
|
'The mediation proxy accepts connections from any local process. It only forwards to declared destinations, so the exposure is the declared set.',
|
|
30
31
|
'A process that double-forks and calls setsid between two supervisor sweeps can outlive the task; only a PID namespace or cgroup closes that gap.',
|
|
31
|
-
'
|
|
32
|
+
'A process-count cap is enforced only on the Linux namespace backend, where it is applied by prlimit after the confinement is built and was proved by an active probe; on macOS it is per-user and system-wide on the host, a soft brake that is carried and not claimed.',
|
|
32
33
|
'Address-space (memory) caps are not enforceable on macOS and are carried, not enforced.',
|
|
33
34
|
]);
|
|
34
35
|
|
|
@@ -73,7 +74,7 @@ export function buildCapabilityReport({ bound, probeReport, level, required }) {
|
|
|
73
74
|
timeoutMs: { requested: m.resources.timeoutMs ?? null, enforced: level === 'enforced', by: 'supervisor deadline with process-tree termination' },
|
|
74
75
|
maxOutputBytes: { requested: m.resources.maxOutputBytes ?? null, enforced: level === 'enforced', by: 'supervisor output cap with process-tree termination' },
|
|
75
76
|
maxFileSizeKb: { requested: m.resources.maxFileSizeKb ?? null, enforced: level === 'enforced' && fileLimitProved, state: c['file-size-limit']?.state ?? 'not-probed' },
|
|
76
|
-
maxProcesses: { requested: m.resources.maxProcesses ?? null, enforced:
|
|
77
|
+
maxProcesses: { requested: m.resources.maxProcesses ?? null, enforced: m.resources.maxProcesses != null && level === 'enforced' && c['process-cap']?.state === 'proved', state: c['process-cap']?.state === 'proved' ? 'proved' : 'unverified' },
|
|
77
78
|
maxMemoryMiB: { requested: m.resources.maxMemoryMiB ?? null, enforced: false, state: 'not-enforced' },
|
|
78
79
|
},
|
|
79
80
|
limitations: [...REPORT_LIMITATIONS],
|
|
@@ -190,7 +190,12 @@ export async function runCapabilityTask(bound, request, opts = {}) {
|
|
|
190
190
|
denyReadPaths: protectedPaths,
|
|
191
191
|
env: taskEnv, networkProxyPort: proxy ? proxy.port : null,
|
|
192
192
|
timeoutMs, graceMs: 1000, maxOutputBytes, signal, force,
|
|
193
|
-
limits:
|
|
193
|
+
limits: {
|
|
194
|
+
...(m.resources.maxFileSizeKb ? { maxFileSizeKb: m.resources.maxFileSizeKb } : {}),
|
|
195
|
+
// Only the namespace backend applies it (prlimit, after the confinement is built). The macOS backend keeps its own ambient-relative
|
|
196
|
+
// default: a requested cap there would be per-user and system-wide, which is not what the manifest asked for.
|
|
197
|
+
...(m.resources.maxProcesses && backend === 'namespace' ? { maxProcs: m.resources.maxProcesses } : {}),
|
|
198
|
+
},
|
|
194
199
|
});
|
|
195
200
|
const executed = r.supervised === true;
|
|
196
201
|
if (!executed) {
|
package/src/sandbox/CLAUDE.md
CHANGED
|
@@ -281,9 +281,9 @@ privilegeDrop`), the same mechanism `limits.js` uses for an unenforceable
|
|
|
281
281
|
limit. It is never silently skipped, and the escape test that covers the
|
|
282
282
|
rebind attack fails if it is missing rather than quietly passing.
|
|
283
283
|
|
|
284
|
-
**
|
|
285
|
-
|
|
286
|
-
|
|
284
|
+
**Executed on a hosted Linux runner.** The `sandbox-linux` job ran the suite
|
|
285
|
+
(see the verification sections below); this section describes code that has
|
|
286
|
+
been exercised, on that runner image and kernel only.
|
|
287
287
|
|
|
288
288
|
## Timeout does not kill the process tree
|
|
289
289
|
|
|
@@ -297,18 +297,24 @@ inside the policy profile — their writes and egress remain confined — but th
|
|
|
297
297
|
are still running and still consuming resources. A caller that needs a hard
|
|
298
298
|
tree kill must implement it.
|
|
299
299
|
|
|
300
|
-
The namespace backend
|
|
301
|
-
|
|
302
|
-
namespace
|
|
303
|
-
|
|
304
|
-
|
|
300
|
+
The namespace backend closes this with the kernel, not a sweep: the command
|
|
301
|
+
runs under `--pid --fork --kill-child`, so when the `unshare` supervisor dies
|
|
302
|
+
the namespace's init gets SIGKILL and the kernel kills every member, including
|
|
303
|
+
a double-forked `setsid` descendant that no process-group or sweep logic can
|
|
304
|
+
find. `--kill-child` is used only where `unshare` advertises it
|
|
305
|
+
(`namespaceTreeKill`); without it supervised execution is refused. Proved by
|
|
306
|
+
the `linux-probes.js` probes on the hosted runner (kill the supervisor, kill
|
|
307
|
+
the init directly). Earlier text here said killing the direct child should reap
|
|
308
|
+
the tree; CI showed it did not (the killed process was the supervisor, not the
|
|
309
|
+
init) until `--kill-child` was added.
|
|
305
310
|
|
|
306
311
|
## Known limitation, deliberately accepted: reads are not confined
|
|
307
312
|
|
|
308
313
|
The userspace policy allows `(allow file-read*)` globally — that backend
|
|
309
314
|
confines **writes**, network egress, and resource use, but **not reads**. (The
|
|
310
|
-
kernel-namespace backend has the same cut
|
|
311
|
-
tree is rebound read-only, not detached, so everything on it stays readable.
|
|
315
|
+
kernel-namespace backend has the same cut in its DEFAULT mode: its mount
|
|
316
|
+
tree is rebound read-only, not detached, so everything on it stays readable.
|
|
317
|
+
Its capability mode, entered with `readRoots`, does confine reads; see below.)
|
|
312
318
|
A confined command can read any file on the host the OS-level
|
|
313
319
|
permissions allow, including outside the sandbox root. Exfiltration of
|
|
314
320
|
readable host files (writing what was read to network or to a location the
|
|
@@ -364,20 +370,37 @@ then runs supervised with protected paths read-denied.
|
|
|
364
370
|
default protected set is the key directory, common credential directories
|
|
365
371
|
under the home directory, plus caller-supplied sealed-label and evidence
|
|
366
372
|
directories. A denylist is a floor, not a claim of host-wide read isolation.
|
|
367
|
-
The namespace backend
|
|
368
|
-
|
|
373
|
+
The namespace backend implements it by masking: an empty tmpfs over a
|
|
374
|
+
protected directory, `/dev/null` over a protected file (in capability mode a
|
|
375
|
+
protected path outside every declared root is simply absent). Proved by the
|
|
376
|
+
protected-canary probes on the hosted runner.
|
|
369
377
|
- **Process trees** (`supervise.js`): own process group, periodic descendant
|
|
370
378
|
sweep, SIGTERM, grace, SIGKILL, then a poll that confirms no survivors; the
|
|
371
379
|
same cleanup runs on a clean exit. Cancel is an `AbortSignal` (status
|
|
372
380
|
`cancelled`). Output is capped. Known gap: a double-fork plus setsid between
|
|
373
381
|
two sweeps escapes; only a PID namespace or cgroup closes it. Supervised
|
|
374
|
-
execution exists
|
|
375
|
-
|
|
382
|
+
execution exists for the userspace backend and for the namespace backend
|
|
383
|
+
where `--kill-child` is available (see above).
|
|
376
384
|
- **Active probes**: write confinement, read denial, env scrub, network, tree
|
|
377
385
|
termination and the file-size limit each run an attack plus a positive
|
|
378
|
-
control through the real backend. `process-cap` is
|
|
379
|
-
|
|
380
|
-
|
|
386
|
+
control through the real backend. `process-cap` is `unverified` on the macOS
|
|
387
|
+
backend and must not be claimed enforced there: the cap is per-uid and
|
|
388
|
+
system-wide, a soft brake. On Linux the earlier non-refusal had a known cause:
|
|
389
|
+
the resource prelude is run by `/bin/sh`, which is dash on the hosted runner,
|
|
390
|
+
and dash's `ulimit` has no `-u` option (`ulimit: Illegal option -u` appears in
|
|
391
|
+
the `sandbox-linux` log), so the cap was never applied at all. The namespace
|
|
392
|
+
backend now applies it with `prlimit --nproc=N --` (util-linux), in the final
|
|
393
|
+
stage and to the caller's command alone (`FINAL_SCRIPT`; a cap applied earlier
|
|
394
|
+
would starve the confinement setup of processes). Inside the user namespace the
|
|
395
|
+
limit binds: the kernel waives RLIMIT_NPROC only for a process holding
|
|
396
|
+
CAP_SYS_RESOURCE or CAP_SYS_ADMIN in the INITIAL namespace, and a namespace root
|
|
397
|
+
has neither (its capability set is dropped as well). With no requested cap none
|
|
398
|
+
is applied (the old ambient-relative default never took effect on Linux), and a
|
|
399
|
+
host without `prlimit` declares `maxProcs` in `unsupported` rather than dropping
|
|
400
|
+
it. `probeProcessCap` (`control-probes.js`) is the proof: six concurrent
|
|
401
|
+
children all start under a generous cap and the kernel refuses some under a cap
|
|
402
|
+
of 3, in default and in capability mode; `linux-enforcement.test.js` runs it
|
|
403
|
+
against runners that ignore the cap and that cap everything.
|
|
381
404
|
`capabilityReport()` carries the standing note that Linux enforcement is
|
|
382
405
|
evidenced only where the `sandbox-linux` CI job ran the same probes.
|
|
383
406
|
- **Status**: target output is returned labelled `untrusted` and is never parsed
|
|
@@ -396,7 +419,27 @@ then runs supervised with protected paths read-denied.
|
|
|
396
419
|
- `writeRoots`: further writable subtrees (readable too). `cwd`: working directory, which must exist.
|
|
397
420
|
- `networkProxyPort`: opens exactly one loopback port (`(remote ip "localhost:PORT")`); DNS, every other port and every other address stay denied.
|
|
398
421
|
|
|
399
|
-
Verified by execution on macOS only (`test/capabilities/`, probes in `capabilities/probes.js`). The namespace backend implements
|
|
422
|
+
Verified by execution on macOS only (`test/capabilities/`, probes in `capabilities/probes.js`). The namespace backend implements `readRoots`, `writeRoots`, `cwd` and
|
|
423
|
+
`denyReadPaths` as **capability mode** (entered when `readRoots` is an array): a
|
|
424
|
+
tmpfs root holding only the runtime baseline (`/usr`, `/bin`, `/lib*`, a few
|
|
425
|
+
identity files, a fresh `/proc` and a minimal `/dev`) plus the declared roots,
|
|
426
|
+
read-only for read roots and read-write for write roots, protected paths absent
|
|
427
|
+
or masked, then `pivot_root` and a detached old root (`linux-rootfs.js` builds
|
|
428
|
+
the plan and the setup script; `backend-namespace.js` runs it). Capability drop
|
|
429
|
+
(`setpriv`, with no-new-privs) is required in this mode, not best effort.
|
|
430
|
+
`networkProxyPort` is REFUSED: mediated network is not implemented on Linux, so
|
|
431
|
+
a task that declares a destination is blocked there.
|
|
432
|
+
|
|
433
|
+
### Linux evidence
|
|
434
|
+
|
|
435
|
+
`linux-probes.js` and the generic probes in `control-probes.js` and
|
|
436
|
+
`capabilities/probes.js` attack each control and pair it with a positive
|
|
437
|
+
control; `test/capabilities/linux-enforcement.test.js` also runs every probe
|
|
438
|
+
against a deliberately wrong runner (fault injection) and requires it to say so.
|
|
439
|
+
`scripts/sandbox-linux-verify.mjs` (the `sandbox-linux` job) fails if a required
|
|
440
|
+
suite skips, a required control is not proved, or a control that must stay
|
|
441
|
+
unasserted claims to be proved, and uploads the evidence table. Exploratory runs
|
|
442
|
+
on a local aarch64 VM are not evidence; only the hosted x86_64 job is.
|
|
400
443
|
|
|
401
444
|
## Extending this module
|
|
402
445
|
|