deepdebug-local-agent 1.0.22 → 1.1.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/.claude/context.md +76 -0
- package/.claude/skills/README.md +25 -0
- package/.claude/skills/tdd-debugging/SKILL.md +53 -0
- package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
- package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
- package/.claude/standards/README.md +51 -0
- package/.claude/standards/clean-code.md +75 -0
- package/.claude/standards/code-review-checklist.md +61 -0
- package/.claude/standards/layered-architecture.md +163 -0
- package/.claude/standards/node.md +45 -0
- package/.claude/standards/security-owasp.md +114 -0
- package/.claude/standards/simplicity.md +42 -0
- package/.claude/standards/solid.md +64 -0
- package/.claude/standards/testing.md +59 -0
- package/.cursor/rules/standards.mdc +17 -0
- package/.github/workflows/build-byoc-image.yml +315 -0
- package/.github/workflows/deploy-gcp.yml +102 -0
- package/.github/workflows/npm-publish.yml +69 -8
- package/AGENTS.md +21 -0
- package/CHANGELOG.md +69 -0
- package/CLAUDE.md +13 -0
- package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
- package/Dockerfile +4 -0
- package/Dockerfile.full +55 -3
- package/README.md +81 -0
- package/bin/install.js +16 -5
- package/cloudbuild-full.yaml +57 -0
- package/docker-compose.yml +5 -3
- package/docs/OBSERVABILITY.md +164 -0
- package/docs/byoc-agent-image.md +70 -0
- package/docs/graphify-packaging-plan.md +133 -0
- package/docs/graphify-validation-run-2026-10-28.md +243 -0
- package/docs/legacy-connectors.md +268 -0
- package/docs/releasing.md +43 -0
- package/docs/signed-commands.md +163 -0
- package/env.example +30 -0
- package/helm/Chart.yaml +2 -2
- package/helm/templates/deployment.yaml +13 -3
- package/helm/templates/networkpolicy.yaml +22 -0
- package/helm/templates/secret.yaml +1 -0
- package/helm/values.yaml +13 -14
- package/mcp-server.js +4 -1
- package/package.json +12 -4
- package/scripts/sync-fixtures.mjs +246 -0
- package/scripts/upgrade-observability-push.sh +227 -0
- package/src/adapters/as400/as400-parser.js +12 -0
- package/src/adapters/as400/index.js +134 -0
- package/src/adapters/cobol/cobol-parser.js +37 -0
- package/src/adapters/cobol/index.js +151 -0
- package/src/adapters/ibmmq/ibmmq-parser.js +23 -0
- package/src/adapters/ibmmq/index.js +137 -0
- package/src/adapters/index.js +79 -0
- package/src/adapters/oracle-forms/index.js +145 -0
- package/src/adapters/oracle-forms/oracle-forms-parser.js +11 -0
- package/src/adapters/sap/index.js +146 -0
- package/src/adapters/sap/sap-parser.js +41 -0
- package/src/ai-engine.js +22 -22
- package/src/config/trusted-signing-keys.json +3 -0
- package/src/detectors/build-tool-detector.js +50 -0
- package/src/detectors/multi-project-detector.js +22 -22
- package/src/detectors/service-detector.js +24 -24
- package/src/exec-utils.js +26 -4
- package/src/git/base-git-provider.js +117 -117
- package/src/git/bitbucket-provider.js +2 -2
- package/src/git/git-provider-registry.js +18 -18
- package/src/git/github-provider.js +13 -13
- package/src/git/multi-repo-pr.js +161 -0
- package/src/mcp-http-server.js +32 -16
- package/src/observability/bundle-files.js +418 -0
- package/src/observability/detect.js +151 -0
- package/src/observability/provision.js +212 -0
- package/src/observability/render-cli.js +59 -0
- package/src/observability/routes.js +88 -0
- package/src/patch/patch-engine.js +28 -28
- package/src/patch-manager.js +85 -85
- package/src/patch_bkp.js +14 -14
- package/src/ports.js +4 -4
- package/src/routes/workspace.route.js +47 -16
- package/src/runtimes/base-runtime.js +75 -75
- package/src/runtimes/java/gradle-runtime.js +17 -17
- package/src/runtimes/java/java-integrations.js +25 -25
- package/src/runtimes/java/maven-runtime.js +34 -34
- package/src/runtimes/node/node-integrations.js +1 -1
- package/src/runtimes/node/npm-runtime.js +14 -14
- package/src/runtimes/node/yarn-runtime.js +4 -4
- package/src/runtimes/runtime-registry.js +25 -25
- package/src/server-local.js +4 -4
- package/src/server.js +1412 -335
- package/src/siblings/burst-suppressor.js +89 -0
- package/src/siblings/capabilities.js +273 -0
- package/src/siblings/env-compat.cjs +58 -0
- package/src/siblings/incident-forwarder.js +320 -0
- package/src/siblings/logger.js +15 -0
- package/src/siblings/metrics.js +94 -0
- package/src/utils/agent-auth.js +184 -0
- package/src/utils/approval-token.js +56 -0
- package/src/utils/bind-host.js +29 -0
- package/src/utils/branch-refspec.js +98 -0
- package/src/utils/child-env.js +34 -0
- package/src/utils/environment-diagnostics.js +55 -55
- package/src/utils/exec-policy.js +962 -0
- package/src/utils/exec-utils.js +15 -15
- package/src/utils/file-count.js +46 -0
- package/src/utils/fs-utils.js +10 -10
- package/src/utils/multi-repo-open.js +132 -0
- package/src/utils/open-gate.js +183 -0
- package/src/utils/ranged-read.js +65 -0
- package/src/utils/request-guards.js +77 -0
- package/src/utils/request-signing.js +342 -0
- package/src/utils/root-recovery.js +244 -0
- package/src/utils/safe-exec-args.js +58 -0
- package/src/utils/sandbox.js +182 -0
- package/src/utils/search-outcome.js +56 -0
- package/src/utils/workspace-root.js +80 -0
- package/src/validators/syntax-validator.js +149 -0
- package/src/workspace/detect-port.js +16 -16
- package/src/workspace/file-reader.js +1 -1
- package/src/workspace/process-manager.js +46 -46
- package/src/workspace-manager.js +86 -24
- package/test/adapters/as400/as400.test.js +121 -0
- package/test/adapters/cobol/cobol.test.js +217 -0
- package/test/adapters/ibmmq/ibmmq.test.js +122 -0
- package/test/adapters/oracle-forms/oracle-forms.test.js +180 -0
- package/test/adapters/registry.test.js +108 -0
- package/test/adapters/sap/sap.test.js +142 -0
- package/test/agent-auth.test.js +194 -0
- package/test/bind-host.test.js +26 -0
- package/test/branch-refspec.test.js +224 -0
- package/test/burst-suppressor.test.js +60 -0
- package/test/capabilities-access.test.js +47 -0
- package/test/capabilities.test.js +87 -0
- package/test/child-env.test.js +23 -0
- package/test/env-compat.test.js +71 -0
- package/test/exec-policy.test.js +463 -0
- package/test/file-count.test.js +64 -0
- package/test/incident-forwarder.test.js +231 -0
- package/test/multi-repo-open.test.js +180 -0
- package/test/multi-repo-pr.test.js +129 -0
- package/test/observability-detect.test.js +199 -0
- package/test/open-gate.test.js +341 -0
- package/test/ranged-read.test.js +60 -0
- package/test/request-guards.test.js +91 -0
- package/test/request-signing.test.js +284 -0
- package/test/root-recovery-sidecar.test.js +171 -0
- package/test/root-recovery.test.js +274 -0
- package/test/safe-exec-args.test.js +58 -0
- package/test/sandbox.test.js +98 -0
- package/test/search-outcome.test.js +50 -0
- package/test/workspace-root.test.js +64 -0
- package/tools/graphify-validation/README.md +92 -0
- package/tools/graphify-validation/config/questions.json +31 -0
- package/tools/graphify-validation/config/repos.json +9 -0
- package/tools/graphify-validation/config/symbols.json +6 -0
- package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
- package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
- package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
- package/tools/graphify-validation/lib/graph_metrics.py +96 -0
- package/tools/graphify-validation/lib/gv_common.py +130 -0
- package/tools/graphify-validation/lib/licenses.py +47 -0
- package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
- package/tools/graphify-validation/lib/recall.py +115 -0
- package/tools/graphify-validation/lib/report.py +226 -0
- package/tools/graphify-validation/lib/run_all.py +237 -0
- package/tools/graphify-validation/lib/run_graphify.py +144 -0
- package/tools/graphify-validation/lib/rusage_helper.py +17 -0
- package/tools/graphify-validation/lib/sample_edges.py +246 -0
- package/tools/graphify-validation/lib/spring_fixture.py +81 -0
- package/tools/graphify-validation/requirements-py311.lock +508 -0
- package/tools/graphify-validation/requirements-tools.in +1 -0
- package/tools/graphify-validation/requirements-tools.lock +371 -0
- package/tools/graphify-validation/requirements.in +1 -0
- package/tools/graphify-validation/requirements.lock +502 -0
- package/tools/graphify-validation/run.sh +43 -0
- package/tools/graphify-validation/tests/test_harness.py +151 -0
- package/cloudbuild-agent-qa.yaml +0 -43
- package/cloudbuild-agent-staging.yaml +0 -40
- package/cloudbuild.yaml.deprecated +0 -42
- package/src/local-agent-source.tar.gz +0 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// test/root-recovery.test.js
|
|
2
|
+
// ROOT-RECOVERY (transversal): a dead persisted X-Workspace-Root
|
|
3
|
+
// ("/mnt/workspaces/<tenant>/<repo>") must resolve to the LIVE clone this
|
|
4
|
+
// agent actually made ("/tmp/workspaces/<owner>_<repo>") - and a root that
|
|
5
|
+
// exists on disk must NEVER be overridden. Proves the QA 2026-07-19 failure
|
|
6
|
+
// mode (v2 loop: prepare clones to one path, reads hit another, 40
|
|
7
|
+
// iterations burned) is fixed at the single resolution point.
|
|
8
|
+
|
|
9
|
+
import { test, describe, before, after } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import {
|
|
15
|
+
recoverLiveRoot,
|
|
16
|
+
resolveEffectiveHeaderRoot,
|
|
17
|
+
tenantOfNfsRoot,
|
|
18
|
+
tenantGateAllows,
|
|
19
|
+
} from '../src/utils/root-recovery.js';
|
|
20
|
+
|
|
21
|
+
// Silence gate-denial warns in tests (still assertable via a collector).
|
|
22
|
+
const quiet = { warn: () => {} };
|
|
23
|
+
|
|
24
|
+
let mount, liveOwnerRepo, liveExact, headerAlive;
|
|
25
|
+
|
|
26
|
+
before(() => {
|
|
27
|
+
mount = fs.mkdtempSync(path.join(os.tmpdir(), 'root-rec-'));
|
|
28
|
+
// cloud-clone layout: <owner>_<repo>
|
|
29
|
+
liveOwnerRepo = path.join(mount, 'VISIONHAMMER_visionhammer-agents');
|
|
30
|
+
// exact-name layout (NFS-style clone under the mount)
|
|
31
|
+
liveExact = path.join(mount, 'simple-api');
|
|
32
|
+
headerAlive = fs.mkdtempSync(path.join(os.tmpdir(), 'root-alive-'));
|
|
33
|
+
for (const d of [liveOwnerRepo, liveExact]) fs.mkdirSync(d, { recursive: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
after(() => {
|
|
37
|
+
for (const d of [mount, headerAlive]) {
|
|
38
|
+
try { fs.rmSync(d, { recursive: true, force: true }); } catch (_) {}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('resolveEffectiveHeaderRoot', () => {
|
|
43
|
+
test('an existing header root is returned unchanged (never overridden)', () => {
|
|
44
|
+
const eff = resolveEffectiveHeaderRoot(headerAlive, {
|
|
45
|
+
registry: [{ id: 'ws-1', root: liveOwnerRepo }],
|
|
46
|
+
mountDir: mount,
|
|
47
|
+
});
|
|
48
|
+
assert.equal(eff.root, headerAlive);
|
|
49
|
+
assert.equal(eff.recovered, false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('dead NFS-era root recovers the owner_repo clone by basename (same tenant)', () => {
|
|
53
|
+
// TENANT GATE: basename recovery now requires the clone to be registered
|
|
54
|
+
// for the SAME tenant as the dead root's first path segment.
|
|
55
|
+
const dead = '/mnt/workspaces/532e03b3-7ab6/visionhammer-agents';
|
|
56
|
+
const eff = resolveEffectiveHeaderRoot(dead, {
|
|
57
|
+
registry: [{ id: 'ws-1', root: liveOwnerRepo, tenantId: '532e03b3-7ab6' }],
|
|
58
|
+
mountDir: mount,
|
|
59
|
+
...quiet,
|
|
60
|
+
});
|
|
61
|
+
assert.equal(eff.root, liveOwnerRepo);
|
|
62
|
+
assert.equal(eff.recovered, true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('dead root with no recoverable clone stays unchanged (behavior preserved)', () => {
|
|
66
|
+
const dead = '/mnt/workspaces/532e03b3-7ab6/totally-unknown-repo';
|
|
67
|
+
const eff = resolveEffectiveHeaderRoot(dead, { registry: [], mountDir: mount });
|
|
68
|
+
assert.equal(eff.root, dead);
|
|
69
|
+
assert.equal(eff.recovered, false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('blank header root resolves to null', () => {
|
|
73
|
+
const eff = resolveEffectiveHeaderRoot(' ', { registry: [], mountDir: mount });
|
|
74
|
+
assert.equal(eff.root, null);
|
|
75
|
+
assert.equal(eff.recovered, false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('recoverLiveRoot', () => {
|
|
80
|
+
test('workspace-id hint wins when the registry has a live root', () => {
|
|
81
|
+
const other = path.join(mount, 'OTHER_visionhammer-agents');
|
|
82
|
+
fs.mkdirSync(other, { recursive: true });
|
|
83
|
+
const live = recoverLiveRoot('/mnt/dead/visionhammer-agents', {
|
|
84
|
+
wsIdHint: 'ws-77',
|
|
85
|
+
registry: [
|
|
86
|
+
{ id: 'ws-77', root: liveOwnerRepo },
|
|
87
|
+
{ id: 'ws-88', root: other },
|
|
88
|
+
],
|
|
89
|
+
mountDir: mount,
|
|
90
|
+
});
|
|
91
|
+
assert.equal(live, liveOwnerRepo);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('registry basename match works without an id hint (same tenant)', () => {
|
|
95
|
+
const live = recoverLiveRoot('/mnt/workspaces/tenant-a/visionhammer-agents', {
|
|
96
|
+
registry: [{ id: 'ws-1', root: liveOwnerRepo, tenantId: 'tenant-a' }],
|
|
97
|
+
mountDir: '/nonexistent-mount',
|
|
98
|
+
...quiet,
|
|
99
|
+
});
|
|
100
|
+
assert.equal(live, liveOwnerRepo);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('disk scan finds exact-name clones when the tenant matches the registered one', () => {
|
|
104
|
+
// Force step 3: the registry knows the tenant for this root, but its
|
|
105
|
+
// recorded root string (trailing slash) is made to fail the step-2
|
|
106
|
+
// exists check; the scan then matches the on-disk dir and the tenant
|
|
107
|
+
// gate passes via the normalized root -> tenant mapping.
|
|
108
|
+
const live = recoverLiveRoot('/mnt/workspaces/tenant-x/simple-api', {
|
|
109
|
+
registry: [{ id: 'ws-1', root: liveExact + path.sep, tenantId: 'tenant-x' }],
|
|
110
|
+
mountDir: mount,
|
|
111
|
+
existsSync: (p) => (p.endsWith(path.sep) ? false : fs.existsSync(p)),
|
|
112
|
+
...quiet,
|
|
113
|
+
});
|
|
114
|
+
assert.equal(live, liveExact);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('conservative matching: a different repo name never matches', () => {
|
|
118
|
+
const live = recoverLiveRoot('/mnt/workspaces/tenant-x/visionhammer', {
|
|
119
|
+
registry: [{ id: 'ws-1', root: liveOwnerRepo }],
|
|
120
|
+
mountDir: mount,
|
|
121
|
+
});
|
|
122
|
+
// "visionhammer" is NOT the basename ("visionhammer-agents") nor a
|
|
123
|
+
// "_visionhammer" suffix match of the clones present.
|
|
124
|
+
assert.equal(live, null);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('degenerate roots never recover', () => {
|
|
128
|
+
for (const dead of ['/', '.', '', null, undefined]) {
|
|
129
|
+
assert.equal(recoverLiveRoot(dead, { registry: [], mountDir: mount }), null);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('never throws on broken injections', () => {
|
|
134
|
+
assert.equal(recoverLiveRoot('/mnt/x/y', {
|
|
135
|
+
registry: [{ id: 'a', root: liveOwnerRepo }],
|
|
136
|
+
mountDir: mount,
|
|
137
|
+
existsSync: () => { throw new Error('boom'); },
|
|
138
|
+
}), null);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// TENANT GATE: on a shared multi-tenant agent, a dead root of tenant A must
|
|
143
|
+
// NEVER recover to a clone hydrated for tenant B just because the repo
|
|
144
|
+
// basenames collide. Unknown tenant on either side fails closed into the
|
|
145
|
+
// structured workspace_root_not_hydrated fail-fast.
|
|
146
|
+
describe('tenant gate', () => {
|
|
147
|
+
test('happy path: same-tenant basename recovery still works (QA-proven E2E scenario)', () => {
|
|
148
|
+
const live = recoverLiveRoot('/mnt/workspaces/tenant-a/visionhammer-agents', {
|
|
149
|
+
registry: [{ id: 'ws-1', root: liveOwnerRepo, tenantId: 'tenant-a' }],
|
|
150
|
+
mountDir: mount,
|
|
151
|
+
...quiet,
|
|
152
|
+
});
|
|
153
|
+
assert.equal(live, liveOwnerRepo);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('basename collision across tenants: NEVER recovers', () => {
|
|
157
|
+
const warns = [];
|
|
158
|
+
const live = recoverLiveRoot('/mnt/workspaces/tenant-a/visionhammer-agents', {
|
|
159
|
+
registry: [{ id: 'ws-1', root: liveOwnerRepo, tenantId: 'tenant-b' }],
|
|
160
|
+
mountDir: '/nonexistent-mount',
|
|
161
|
+
warn: (m) => warns.push(m),
|
|
162
|
+
});
|
|
163
|
+
assert.equal(live, null);
|
|
164
|
+
assert.equal(warns.length, 1);
|
|
165
|
+
assert.match(warns[0], /TENANT GATE/);
|
|
166
|
+
// Decision logs carry tenant ids + basenames only - never URLs/tokens.
|
|
167
|
+
assert.doesNotMatch(warns[0], /https?:\/\//);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('unknown candidate tenant (clone registered without tenantId): no basename recovery', () => {
|
|
171
|
+
const live = recoverLiveRoot('/mnt/workspaces/tenant-a/visionhammer-agents', {
|
|
172
|
+
registry: [{ id: 'ws-1', root: liveOwnerRepo }],
|
|
173
|
+
mountDir: '/nonexistent-mount',
|
|
174
|
+
...quiet,
|
|
175
|
+
});
|
|
176
|
+
assert.equal(live, null);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('unknown dead-root tenant (non-NFS path, no hint): no basename/scan recovery', () => {
|
|
180
|
+
const live = recoverLiveRoot('/opt/legacy/visionhammer-agents', {
|
|
181
|
+
registry: [{ id: 'ws-1', root: liveOwnerRepo, tenantId: 'tenant-a' }],
|
|
182
|
+
mountDir: mount,
|
|
183
|
+
...quiet,
|
|
184
|
+
});
|
|
185
|
+
assert.equal(live, null);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('tenantHint fallback unlocks recovery for non-NFS dead roots of the SAME tenant', () => {
|
|
189
|
+
const live = recoverLiveRoot('/opt/legacy/visionhammer-agents', {
|
|
190
|
+
tenantHint: 'tenant-a',
|
|
191
|
+
registry: [{ id: 'ws-1', root: liveOwnerRepo, tenantId: 'tenant-a' }],
|
|
192
|
+
mountDir: '/nonexistent-mount',
|
|
193
|
+
...quiet,
|
|
194
|
+
});
|
|
195
|
+
assert.equal(live, liveOwnerRepo);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('workspace-id match with DIVERGENT known tenants: no recovery (fail-fast)', () => {
|
|
199
|
+
const warns = [];
|
|
200
|
+
const live = recoverLiveRoot('/mnt/workspaces/tenant-a/visionhammer-agents', {
|
|
201
|
+
wsIdHint: 'ws-77',
|
|
202
|
+
registry: [{ id: 'ws-77', root: liveOwnerRepo, tenantId: 'tenant-b' }],
|
|
203
|
+
mountDir: '/nonexistent-mount',
|
|
204
|
+
warn: (m) => warns.push(m),
|
|
205
|
+
});
|
|
206
|
+
assert.equal(live, null);
|
|
207
|
+
assert.equal(warns.length, 1);
|
|
208
|
+
assert.match(warns[0], /TENANT GATE.*id match denied/);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('workspace-id match with unknown tenant on either side still recovers (id is strong)', () => {
|
|
212
|
+
// Candidate tenant unknown:
|
|
213
|
+
assert.equal(recoverLiveRoot('/mnt/workspaces/tenant-a/visionhammer-agents', {
|
|
214
|
+
wsIdHint: 'ws-77',
|
|
215
|
+
registry: [{ id: 'ws-77', root: liveOwnerRepo }],
|
|
216
|
+
mountDir: '/nonexistent-mount',
|
|
217
|
+
...quiet,
|
|
218
|
+
}), liveOwnerRepo);
|
|
219
|
+
// Dead-root tenant unknown:
|
|
220
|
+
assert.equal(recoverLiveRoot('/opt/legacy/anything', {
|
|
221
|
+
wsIdHint: 'ws-77',
|
|
222
|
+
registry: [{ id: 'ws-77', root: liveOwnerRepo, tenantId: 'tenant-b' }],
|
|
223
|
+
mountDir: '/nonexistent-mount',
|
|
224
|
+
...quiet,
|
|
225
|
+
}), liveOwnerRepo);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('disk scan: unregistered clone dir (unknown tenant) is DENIED', () => {
|
|
229
|
+
const live = recoverLiveRoot('/mnt/workspaces/tenant-x/simple-api', {
|
|
230
|
+
registry: [],
|
|
231
|
+
mountDir: mount,
|
|
232
|
+
...quiet,
|
|
233
|
+
});
|
|
234
|
+
assert.equal(live, null);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('disk scan: clone registered for ANOTHER tenant is DENIED', () => {
|
|
238
|
+
const warns = [];
|
|
239
|
+
const live = recoverLiveRoot('/mnt/workspaces/tenant-x/simple-api', {
|
|
240
|
+
registry: [{ id: 'ws-1', root: liveExact + path.sep, tenantId: 'tenant-y' }],
|
|
241
|
+
mountDir: mount,
|
|
242
|
+
existsSync: (p) => (p.endsWith(path.sep) ? false : fs.existsSync(p)),
|
|
243
|
+
warn: (m) => warns.push(m),
|
|
244
|
+
});
|
|
245
|
+
assert.equal(live, null);
|
|
246
|
+
assert.ok(warns.some((m) => /TENANT GATE/.test(m)));
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('tenantOfNfsRoot', () => {
|
|
251
|
+
test('extracts the first segment after /mnt/workspaces/', () => {
|
|
252
|
+
assert.equal(tenantOfNfsRoot('/mnt/workspaces/2f99f61f-0a72/pure-core-ms'), '2f99f61f-0a72');
|
|
253
|
+
assert.equal(tenantOfNfsRoot('/mnt/workspaces/t1/repo/sub/dir'), 't1');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('non-NFS or degenerate paths stay unknown (null)', () => {
|
|
257
|
+
assert.equal(tenantOfNfsRoot('/tmp/workspaces/OWNER_repo'), null);
|
|
258
|
+
assert.equal(tenantOfNfsRoot('/mnt/workspaces/only-tenant'), null);
|
|
259
|
+
assert.equal(tenantOfNfsRoot(''), null);
|
|
260
|
+
assert.equal(tenantOfNfsRoot(null), null);
|
|
261
|
+
assert.equal(tenantOfNfsRoot(undefined), null);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('tenantGateAllows', () => {
|
|
266
|
+
test('allows only known AND equal tenants', () => {
|
|
267
|
+
assert.equal(tenantGateAllows('a', 'a'), true);
|
|
268
|
+
assert.equal(tenantGateAllows('a', 'b'), false);
|
|
269
|
+
assert.equal(tenantGateAllows(null, 'a'), false);
|
|
270
|
+
assert.equal(tenantGateAllows('a', null), false);
|
|
271
|
+
assert.equal(tenantGateAllows(null, null), false);
|
|
272
|
+
assert.equal(tenantGateAllows('', ''), false);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// test/safe-exec-args.test.js - H4: request text stays data, never shell syntax.
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { buildGrepArgs, buildTarArgs, parseAuthCommand } from '../src/utils/safe-exec-args.js';
|
|
9
|
+
|
|
10
|
+
const PAYLOADS = ['"; touch PWNED; echo "', '$(touch PWNED)', '`touch PWNED`', "'; touch PWNED; '", 'a\\', '--include=x -r /etc'];
|
|
11
|
+
|
|
12
|
+
test('grep argv carries pattern and filter as single entries', () => {
|
|
13
|
+
const args = buildGrepArgs({ pattern: '"; id #', fileFilter: '*.js" -r / "', root: '/ws' });
|
|
14
|
+
assert.ok(args.includes('--include=*.js" -r / "'));
|
|
15
|
+
assert.deepEqual(args.slice(-4), ['-e', '"; id #', '--', '/ws']);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('grep with injection payloads never executes them', () => {
|
|
19
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'h4-'));
|
|
20
|
+
fs.writeFileSync(path.join(dir, 'a.txt'), 'hello world\n');
|
|
21
|
+
for (const p of PAYLOADS) {
|
|
22
|
+
for (const f of [undefined, p]) {
|
|
23
|
+
try { execFileSync('grep', buildGrepArgs({ pattern: p, fileFilter: f, root: dir }), { cwd: dir, encoding: 'utf8' }); }
|
|
24
|
+
catch { /* exit 1/2 is fine: no match / bad pattern */ }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
assert.equal(fs.existsSync(path.join(dir, 'PWNED')), false);
|
|
28
|
+
assert.equal(fs.existsSync(path.join(process.cwd(), 'PWNED')), false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a leading dash pattern is a pattern, not an option', () => {
|
|
32
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'h4-'));
|
|
33
|
+
fs.writeFileSync(path.join(dir, 'a.txt'), 'x -rf y\n');
|
|
34
|
+
const out = execFileSync('grep', buildGrepArgs({ pattern: '-rf', root: dir }), { encoding: 'utf8' });
|
|
35
|
+
assert.match(out, /-rf/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('grep args reject empty pattern/root', () => {
|
|
39
|
+
assert.throws(() => buildGrepArgs({ pattern: '', root: '/x' }));
|
|
40
|
+
assert.throws(() => buildGrepArgs({ pattern: 'a', root: '' }));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('tar argv keeps paths as entries', () => {
|
|
44
|
+
const a = buildTarArgs({ tarPath: '/tmp/x";id;".tgz', wsPath: '/mnt/$(id)' });
|
|
45
|
+
assert.equal(a[1], '/tmp/x";id;".tgz');
|
|
46
|
+
assert.equal(a[a.indexOf('-C') + 1], '/mnt/$(id)');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('auth commands: only interactive logins with safe args', () => {
|
|
50
|
+
assert.deepEqual(parseAuthCommand('gcloud auth login --no-launch-browser').argv, ['gcloud', 'auth', 'login', '--no-launch-browser']);
|
|
51
|
+
assert.equal(parseAuthCommand('az login').ok, true);
|
|
52
|
+
assert.equal(parseAuthCommand('aws sso login --profile prod-1').ok, true);
|
|
53
|
+
for (const bad of ['gcloud auth login; rm -rf /', 'gh auth login && id', 'gh auth login $(id)', 'gh auth login `id`',
|
|
54
|
+
'gh auth login | sh', 'rm -rf /', 'gcloud compute ssh x', '', 'az login\nid', 'gh auth login >out', 'gh auth login &']) {
|
|
55
|
+
assert.equal(parseAuthCommand(bad).ok, false, bad);
|
|
56
|
+
}
|
|
57
|
+
assert.equal(parseAuthCommand(undefined).ok, false);
|
|
58
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// test/sandbox.test.js
|
|
2
|
+
// SEG-3 (P0): per-workspace filesystem/exec confinement. Proves the A3 escape
|
|
3
|
+
// (reading a sibling workspace) is denied and multi-repo access is allowed.
|
|
4
|
+
|
|
5
|
+
import { test, describe, before, after } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import {
|
|
11
|
+
confineToRoot,
|
|
12
|
+
isInsideRoot,
|
|
13
|
+
checkArgsWithinRoot,
|
|
14
|
+
scanRawCommand,
|
|
15
|
+
SandboxViolation,
|
|
16
|
+
} from '../src/utils/sandbox.js';
|
|
17
|
+
|
|
18
|
+
let base, wsA, wsA2, wsB;
|
|
19
|
+
|
|
20
|
+
before(() => {
|
|
21
|
+
base = fs.mkdtempSync(path.join(os.tmpdir(), 'sbx-'));
|
|
22
|
+
wsA = path.join(base, 'ws-A'); // the workspace under test (2 repos)
|
|
23
|
+
wsA2 = path.join(base, 'ws-A-2'); // sibling with a prefix-colliding name
|
|
24
|
+
wsB = path.join(base, 'ws-B'); // another workspace of the same tenant
|
|
25
|
+
for (const d of [path.join(wsA, 'repo1/src'), path.join(wsA, 'repo2/src'), wsA2, path.join(wsB, 'repo1')]) {
|
|
26
|
+
fs.mkdirSync(d, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
fs.writeFileSync(path.join(wsA, 'repo1/src/a.txt'), 'A');
|
|
29
|
+
fs.writeFileSync(path.join(wsA, 'repo2/src/b.txt'), 'B');
|
|
30
|
+
fs.writeFileSync(path.join(wsA2, 'secret.txt'), 'SIBLING');
|
|
31
|
+
fs.writeFileSync(path.join(wsB, 'repo1/x.txt'), 'OTHER');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
after(() => { try { fs.rmSync(base, { recursive: true, force: true }); } catch {} });
|
|
35
|
+
|
|
36
|
+
describe('confineToRoot — multi-repo allowed, everything else denied', () => {
|
|
37
|
+
test('both repos of the workspace are readable (multi-repo)', () => {
|
|
38
|
+
assert.equal(confineToRoot(wsA, 'repo1/src/a.txt'), path.join(fs.realpathSync(wsA), 'repo1/src/a.txt'));
|
|
39
|
+
assert.equal(confineToRoot(wsA, 'repo2/src/b.txt'), path.join(fs.realpathSync(wsA), 'repo2/src/b.txt'));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('A3 regression: `../` into a sibling workspace is denied', () => {
|
|
43
|
+
assert.throws(() => confineToRoot(wsA, '../ws-B/repo1/x.txt'), SandboxViolation);
|
|
44
|
+
assert.equal(isInsideRoot(wsA, '../ws-B/repo1/x.txt'), false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('prefix-collision sibling (ws-A-2) is denied', () => {
|
|
48
|
+
assert.equal(isInsideRoot(wsA, '../ws-A-2/secret.txt'), false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('absolute path into another workspace is denied', () => {
|
|
52
|
+
assert.equal(isInsideRoot(wsA, path.join(wsB, 'repo1/x.txt')), false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('absolute /etc/passwd is denied', () => {
|
|
56
|
+
assert.equal(isInsideRoot(wsA, '/etc/passwd'), false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('run_command argument / raw-command confinement', () => {
|
|
61
|
+
test('tokenized args: `..` path arg denied, normal flags allowed', () => {
|
|
62
|
+
assert.equal(checkArgsWithinRoot(wsA, ['cat', '../ws-B/repo1/x.txt']).ok, false);
|
|
63
|
+
assert.equal(checkArgsWithinRoot(wsA, ['mvn', 'test', '-Dtest=*.Foo']).ok, true);
|
|
64
|
+
assert.equal(checkArgsWithinRoot(wsA, ['cat', 'repo1/src/a.txt']).ok, true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('tokenized args: absolute /etc/passwd denied (run escape regression)', () => {
|
|
68
|
+
// Before the fix, checkArgsWithinRoot stripped the leading slash and
|
|
69
|
+
// re-based /etc/passwd under the root -> ok:true, and /workspace/run then
|
|
70
|
+
// executed the real /etc/passwd. It must be DENIED as filesystem-absolute.
|
|
71
|
+
assert.equal(checkArgsWithinRoot(wsA, ['cat', '/etc/passwd']).ok, false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('tokenized args: absolute path into another workspace denied', () => {
|
|
75
|
+
// The A3 cross-workspace escape, but via an ABSOLUTE arg to /workspace/run.
|
|
76
|
+
assert.equal(checkArgsWithinRoot(wsA, ['cat', path.join(wsB, 'repo1/x.txt')]).ok, false);
|
|
77
|
+
// /tmp/workspaces/<other> shape is also denied even when the dir is absent.
|
|
78
|
+
assert.equal(checkArgsWithinRoot(wsA, ['cat', '/tmp/workspaces/other-ws/x']).ok, false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('tokenized args: valid relative path INSIDE the root still passes', () => {
|
|
82
|
+
assert.equal(checkArgsWithinRoot(wsA, ['cat', 'repo2/src/b.txt']).ok, true);
|
|
83
|
+
assert.equal(checkArgsWithinRoot(wsA, ['node', 'repo1/src/a.txt']).ok, true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('tokenized args: absolute path INSIDE the root passes; system binaries pass', () => {
|
|
87
|
+
assert.equal(checkArgsWithinRoot(wsA, ['cat', path.join(fs.realpathSync(wsA), 'repo1/src/a.txt')]).ok, true);
|
|
88
|
+
assert.equal(checkArgsWithinRoot(wsA, ['/usr/bin/node', '--version']).ok, true);
|
|
89
|
+
assert.equal(checkArgsWithinRoot(wsA, ['/bin/ls', '-la']).ok, true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('raw command: traversal / cd-out / absolute-into-sibling denied', () => {
|
|
93
|
+
assert.equal(scanRawCommand(wsA, 'cat ../ws-B/repo1/x.txt').ok, false);
|
|
94
|
+
assert.equal(scanRawCommand(wsA, 'cd /etc && cat passwd').ok, false);
|
|
95
|
+
assert.equal(scanRawCommand(wsA, 'cat ' + path.join(wsA2, 'secret.txt')).ok, false);
|
|
96
|
+
assert.equal(scanRawCommand(wsA, 'mvn -q -DskipTests package').ok, true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// test/search-outcome.test.js
|
|
2
|
+
// /workspace/search must never answer HTTP 500 to a grep timeout: QA raised an
|
|
3
|
+
// incident on the agent itself and the AI lost the partial matches.
|
|
4
|
+
|
|
5
|
+
import { test, describe } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { classifyGrepFailure, partialSearchBody } from '../src/utils/search-outcome.js';
|
|
8
|
+
|
|
9
|
+
describe('classifyGrepFailure', () => {
|
|
10
|
+
test('exit 1 is no match', () => {
|
|
11
|
+
assert.deepEqual(classifyGrepFailure({ status: 1 }), { kind: 'no_match' });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('exit 2 is a bad pattern', () => {
|
|
15
|
+
assert.equal(classifyGrepFailure({ status: 2, message: 'grep: x' }).kind, 'bad_pattern');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('execSync timeout (killed, SIGTERM, status null) is a partial answer, not a crash', () => {
|
|
19
|
+
const r = classifyGrepFailure(
|
|
20
|
+
{ killed: true, signal: 'SIGTERM', status: null, stdout: '/root/a.js:1:x\n/root/b.js:2:y\n' },
|
|
21
|
+
{ timeoutMs: 15000 });
|
|
22
|
+
assert.equal(r.kind, 'partial');
|
|
23
|
+
assert.equal(r.reason, 'search_timeout');
|
|
24
|
+
assert.ok(r.stdout.includes('b.js'));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('ENOBUFS is a partial answer', () => {
|
|
28
|
+
assert.equal(classifyGrepFailure({ code: 'ENOBUFS', stdout: 'x' }).reason, 'output_cap');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('anything else stays unknown (caller rethrows)', () => {
|
|
32
|
+
assert.equal(classifyGrepFailure({ status: 127 }).kind, 'unknown');
|
|
33
|
+
assert.equal(classifyGrepFailure(null).kind, 'unknown');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('partialSearchBody', () => {
|
|
38
|
+
test('200-shaped body with relativized partial matches and a narrowing hint', () => {
|
|
39
|
+
const body = partialSearchBody('foo',
|
|
40
|
+
{ reason: 'search_timeout', stdout: '/root/a.js:1:foo\n\n/root/b.js:2:foo\n', timeoutMs: 15000 },
|
|
41
|
+
(l) => l.replace('/root/', ''));
|
|
42
|
+
assert.equal(body.ok, false);
|
|
43
|
+
assert.equal(body.error, 'search_timeout');
|
|
44
|
+
assert.equal(body.partial, true);
|
|
45
|
+
assert.equal(body.count, 2);
|
|
46
|
+
assert.equal(body.results, 'a.js:1:foo\nb.js:2:foo');
|
|
47
|
+
assert.match(body.hint, /15 s/);
|
|
48
|
+
assert.match(body.hint, /file_filter/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// test/workspace-root.test.js
|
|
2
|
+
// SECURITY REVIEW 2026-10-11 (H4): X-Workspace-Root was used verbatim as the
|
|
3
|
+
// sandbox root and is not covered by the request signature.
|
|
4
|
+
import { test, describe } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { validateHeaderRoot, workspaceBases, exactWorkspaceRoots } from '../src/utils/workspace-root.js';
|
|
7
|
+
|
|
8
|
+
const BASES = ['/mnt/workspaces', '/tmp/workspaces'];
|
|
9
|
+
const NOEXACT = [];
|
|
10
|
+
const REG = [
|
|
11
|
+
{ id: 'ws-a', root: '/srv/clones/acme_app', tenantId: 'tenant-a' },
|
|
12
|
+
{ id: 'ws-b', root: '/mnt/workspaces/tenant-b/ws-b', tenantId: 'tenant-b' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
describe('validateHeaderRoot', () => {
|
|
16
|
+
test('a workspace under a base directory is accepted and normalised', () => {
|
|
17
|
+
const r = validateHeaderRoot('/mnt/workspaces/tenant-a/ws-1/', { bases: BASES, exact: NOEXACT, registry: [] });
|
|
18
|
+
assert.deepEqual(r, { ok: true, root: '/mnt/workspaces/tenant-a/ws-1' });
|
|
19
|
+
});
|
|
20
|
+
test('filesystem root, relative paths and the bare base are refused', () => {
|
|
21
|
+
assert.equal(validateHeaderRoot('/', { bases: BASES, exact: NOEXACT }).ok, false);
|
|
22
|
+
assert.equal(validateHeaderRoot('workspaces/x', { bases: BASES, exact: NOEXACT }).ok, false);
|
|
23
|
+
assert.equal(validateHeaderRoot('/mnt/workspaces', { bases: BASES, exact: NOEXACT }).ok, false);
|
|
24
|
+
assert.equal(validateHeaderRoot('', { bases: BASES, exact: NOEXACT }).ok, false);
|
|
25
|
+
});
|
|
26
|
+
test('traversal out of a base is refused', () => {
|
|
27
|
+
assert.equal(validateHeaderRoot('/mnt/workspaces/../../etc', { bases: BASES, exact: NOEXACT }).ok, false);
|
|
28
|
+
assert.equal(validateHeaderRoot('/mnt/workspaces-evil/x', { bases: BASES, exact: NOEXACT }).ok, false);
|
|
29
|
+
});
|
|
30
|
+
test('directories outside every base are refused (/etc, /app, /home)', () => {
|
|
31
|
+
for (const p of ['/etc', '/app/src', '/home/deepdebug/.deepdebug', '/proc/self']) {
|
|
32
|
+
assert.equal(validateHeaderRoot(p, { bases: BASES, exact: NOEXACT, registry: [] }).ok, false, p);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
test('a registered workspace outside the bases is accepted (dev/local clones)', () => {
|
|
36
|
+
assert.equal(validateHeaderRoot('/srv/clones/acme_app/src', { bases: BASES, exact: NOEXACT, registry: REG }).ok, true);
|
|
37
|
+
});
|
|
38
|
+
test('a signed tenant cannot name another tenant\'s registered workspace', () => {
|
|
39
|
+
const r = validateHeaderRoot('/mnt/workspaces/tenant-b/ws-b', { bases: BASES, exact: NOEXACT, registry: REG, tenantId: 'tenant-a' });
|
|
40
|
+
assert.equal(r.ok, false);
|
|
41
|
+
assert.match(r.reason, /another tenant/);
|
|
42
|
+
assert.equal(validateHeaderRoot('/mnt/workspaces/tenant-b/ws-b', { bases: BASES, exact: NOEXACT, registry: REG, tenantId: 'tenant-b' }).ok, true);
|
|
43
|
+
// unsigned request: registry ownership is not asserted (signature mode warn)
|
|
44
|
+
assert.equal(validateHeaderRoot('/mnt/workspaces/tenant-b/ws-b', { bases: BASES, exact: NOEXACT, registry: REG }).ok, true);
|
|
45
|
+
});
|
|
46
|
+
test('NUL bytes are refused', () => {
|
|
47
|
+
assert.equal(validateHeaderRoot('/mnt/workspaces/a\0b', { bases: BASES, exact: NOEXACT }).ok, false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('workspaceBases / exactWorkspaceRoots', () => {
|
|
52
|
+
test('defaults plus LOCAL_AGENT_WORKSPACE_BASES; root and relative entries dropped', () => {
|
|
53
|
+
const b = workspaceBases({ LOCAL_AGENT_WORKSPACE_BASES: '/data/ws, relative, /' });
|
|
54
|
+
assert.deepEqual(b, ['/mnt/workspaces', '/tmp/workspaces', '/data/ws']);
|
|
55
|
+
});
|
|
56
|
+
test('WORKSPACE_ROOT is a workspace itself: accepted exactly or below, never "/"', () => {
|
|
57
|
+
assert.deepEqual(exactWorkspaceRoots({ WORKSPACE_ROOT: '/workspace/' }), ['/workspace']);
|
|
58
|
+
assert.deepEqual(exactWorkspaceRoots({ WORKSPACE_ROOT: '/' }), []);
|
|
59
|
+
const exact = exactWorkspaceRoots({ WORKSPACE_ROOT: '/workspace' });
|
|
60
|
+
assert.equal(validateHeaderRoot('/workspace', { bases: BASES, exact }).ok, true);
|
|
61
|
+
assert.equal(validateHeaderRoot('/workspace/sub', { bases: BASES, exact }).ok, true);
|
|
62
|
+
assert.equal(validateHeaderRoot('/workspace2', { bases: BASES, exact }).ok, false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Graphify validation harness (T4-06, D8)
|
|
2
|
+
|
|
3
|
+
Reproducible measurements for the Graphify go/no-go criteria G1-G10 of the Local Agent GA plan
|
|
4
|
+
(`10 Library/Plans/Local Agent GA Plano 2026-10-28.md`, section 2) and for the pilot note
|
|
5
|
+
(`10 Library/Recon & Reviews/Graphify Piloto 2026-10-27.md`). A human or an agent runs one command and
|
|
6
|
+
gets `REPORT.md` plus the raw JSON/CSV behind every number.
|
|
7
|
+
|
|
8
|
+
It is a **validation tool, not part of the agent runtime**: nothing in `src/`, `bin/`, `Dockerfile*`
|
|
9
|
+
or `package.json` changes, no flag exists, and the Node agent never executes anything from here.
|
|
10
|
+
|
|
11
|
+
## What it measures
|
|
12
|
+
|
|
13
|
+
| Criterion | What the harness does | Needs outside this repo |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| G1 extraction | share of code files (java, ts, tsx, js, jsx, mjs, cjs, py, go, tf; no pom/SQL/markdown) with at least one symbol; lists files without symbols (syntax errors) | nothing |
|
|
16
|
+
| G2 edges | INFERRED share per repo; seeded stratified sample of INFERRED `calls` edges (20 ambiguous + 40 other, non-test sources) judged by an **automatic oracle** (Java: receiver type declared in the file versus the class owning the target method); CSV for the manual two-person review | the human review (fill `manual_verdict`, rerun with `--manual-dir`) |
|
|
17
|
+
| G3 Spring/DI | builds the graph of `fixtures/spring` (`@Bean`, `@Qualifier`, interface injection, Lombok) and checks `fixtures/spring/expected.json` by group | nothing |
|
|
18
|
+
| G4 performance | wall time and peak RSS of the whole process tree (sampled every 250 ms) for a cold run and for an incremental run after a one-file change | a 2 vCPU / 4 GB machine for the real limit |
|
|
19
|
+
| G5 recall | `graphify affected` (depth 1) versus `rg -w` per symbol; recall@8 of `graphify query` versus a keyword (ripgrep) baseline over `config/questions.json`; optional pgvector column | pgvector results (`--vector-results`, format below) |
|
|
20
|
+
| G7 security | cold run with the network denied (macOS `sandbox-exec`, Linux `unshare -n`), a `sys.addaudithook` hook counting `socket.*` and exec events, proof that the block is effective, `pip-audit` of the pinned set | nothing |
|
|
21
|
+
| G8 hygiene | two cold runs must give the same node/edge fingerprint; counts `graphify-out/` files versioned at the ref | nothing |
|
|
22
|
+
| licenses | per-distribution license inventory of the installed set | legal review |
|
|
23
|
+
| G6, G9, G10 | **not measured** (need LLM runs, Docker, the pilot repo); the report says so | see `docs/graphify-packaging-plan.md` |
|
|
24
|
+
|
|
25
|
+
## Run it
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# clones of the VisionHammer repos live in $REPOS (only `git archive <ref>` is used: read-only)
|
|
29
|
+
RG=$(command -v rg) PYTHON=python3.12 \
|
|
30
|
+
tools/graphify-validation/run.sh --repos-dir "$REPOS" --out /some/scratch/dir --pip-audit
|
|
31
|
+
# optional: --fetch (git fetch origin in each clone), --repo vh-api-gateway, --only cold,edges,
|
|
32
|
+
# --vector-results vectors.json, --manual-dir reviewed/
|
|
33
|
+
python3 -m unittest discover -s tools/graphify-validation/tests # harness self-tests, no graphify needed
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Output: `OUT/REPORT.md`, `OUT/results/*.json`, `OUT/review/<repo>.csv`, `OUT/audit-<repo>.tsv`.
|
|
37
|
+
`OUT` must be outside any git worktree and outside iCloud folders.
|
|
38
|
+
|
|
39
|
+
Guarantees built in:
|
|
40
|
+
|
|
41
|
+
- **Pinned and hashed install**: `requirements.lock` (Python 3.12) is installed with
|
|
42
|
+
`--require-hashes --no-deps --only-binary=:all:`; `requirements.in` is the single input
|
|
43
|
+
(`graphifyy[sql,terraform]==0.9.93`). The extras `mcp`, `leiden` and every LLM extra are **not** installed.
|
|
44
|
+
`requirements-py311.lock` is the same set resolved for Python 3.11 (Debian bookworm, `local-agent-full`).
|
|
45
|
+
- **Only VisionHammer repos**: `config/repos.json` lists the allowed GitHub owner; the harness refuses a clone whose
|
|
46
|
+
`origin` is not under it. Customer repos are out of scope by construction.
|
|
47
|
+
- **Clean environment**: graphify runs with only `HOME` (an empty dir), `PATH`, `LANG`, `PYTHONHASHSEED`, `NO_COLOR`.
|
|
48
|
+
No LLM keys, no cloud credentials, no proxies. `label`, `cluster-only`, `add`, `clone`, `prs` are never invoked.
|
|
49
|
+
- **Snapshots, not worktrees**: each run extracts `git archive <ref>` into `OUT/work/<repo>` and removes any versioned
|
|
50
|
+
`graphify-out/`, so the checkout is never touched and results are reproducible per commit.
|
|
51
|
+
- **Network**: the only network use is `git fetch origin` (opt-in `--fetch`), `pip install` from PyPI on the first run
|
|
52
|
+
and `pip-audit` (PyPI/OSV). Graphify itself runs offline in the blocked step.
|
|
53
|
+
|
|
54
|
+
## Reading the report
|
|
55
|
+
|
|
56
|
+
Verdicts: `PASSA` / `FALHA` against the proposed limits (G1 >= 95 %, G2 INFERRED <= 15 % and precision >= 85 %,
|
|
57
|
+
G3 >= 90 %, G4 cold <= 60 s and <= 1 GB RSS for repos <= 2 000 files and incremental <= 30 s, G5 "who affects"
|
|
58
|
+
recall >= 90 %); `PARCIAL` = measured, but a human or environment step is missing; `NAO MEDIDO`.
|
|
59
|
+
|
|
60
|
+
Known limits of the measurements (also printed in the report):
|
|
61
|
+
|
|
62
|
+
- The G2 precision is a **proxy**: the Java oracle checks the receiver's declared type, it does not read the code like
|
|
63
|
+
a reviewer. `unknown` rows (chained calls, `var`, lambda parameters, constructors) are excluded from the precision and
|
|
64
|
+
listed in the CSV. For non-Java files only a weak check (callee name on the line and target module imported) exists.
|
|
65
|
+
- `peak_tree_rss_mb` is a sampled sum that can double-count shared pages; `max_child_rss_mb` is the kernel `ru_maxrss` of the largest child (fresh helper process per run).
|
|
66
|
+
- The `affected` reference (`rg -w`) also matches comments, strings and same-named types, so "precision vs rg" is not
|
|
67
|
+
graph precision; "recall vs rg" is the number that matters for "who is affected".
|
|
68
|
+
- The recall@8 question set is small and hand-written (`config/questions.json`); extend it per repo before relying
|
|
69
|
+
on it for a decision. Test files are removed from every ranking, as the loop context rule requires.
|
|
70
|
+
- Determinism excludes community ids (they change with every edit, see the pilot note).
|
|
71
|
+
|
|
72
|
+
`vector-results.json` format (to fill the pgvector column): `{"gw-1": ["src/main/.../PolicyGate.java", ...], ...}`,
|
|
73
|
+
ranked, repo-relative paths, produced from the `RagEvalController` dataset with the same questions.
|
|
74
|
+
|
|
75
|
+
## Layout
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
run.sh entry point (venv from the hashed lock, run, optional pip-audit, report)
|
|
79
|
+
requirements.in / .lock Graphify pin for Python 3.12 (harness venv)
|
|
80
|
+
requirements-py311.lock same pin resolved for Python 3.11 (image packaging plan)
|
|
81
|
+
requirements-tools.* pip-audit pin (tool venv, hashed)
|
|
82
|
+
config/ repos (allow-list + refs), symbols for affected-vs-rg, reference questions
|
|
83
|
+
fixtures/spring/ synthetic Spring project + expected.json
|
|
84
|
+
lib/ run_all (orchestrator), run_graphify (time/RSS/network), graph_metrics, sample_edges,
|
|
85
|
+
affected_vs_rg, recall, spring_fixture, licenses, report, audit_hook/
|
|
86
|
+
tests/ unittest self-tests for the harness logic
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Upgrading Graphify: edit `requirements.in`, regenerate both locks
|
|
90
|
+
(`uv pip compile requirements.in --python-version 3.12 --generate-hashes --no-header -o requirements.lock`, and the same with
|
|
91
|
+
`--python-version 3.11 --python-platform x86_64-unknown-linux-gnu --only-binary :all: -o requirements-py311.lock`),
|
|
92
|
+
rerun the harness, attach the new report to the PR. Policy: no version younger than 7 days.
|