flow-projection-generator-security.test.mjs
167 lines 5.5 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 21 hours ago
1 /**
2 * Tier 7 — SECURITY: no secrets, injection inert, scope denial, harness fail-closed.
3 *
4 * @see docs/FLOW-PROJECTION-GENERATOR-CONTRACT-7A-11.md §9
5 */
6 import { describe, it, beforeEach, afterEach } from 'node:test';
7 import assert from 'node:assert/strict';
8 import fs from 'node:fs';
9 import path from 'node:path';
10 import { fileURLToPath } from 'node:url';
11 import { handleFlowProjectRequest } from '../lib/flow/flow-handlers.mjs';
12 import {
13 projectFlow,
14 detectDrift,
15 isHarnessActive,
16 } from '../lib/flow/projection-generator.mjs';
17 import {
18 saveFlowStore,
19 validateFlowBundle,
20 buildFlowStepId,
21 } from '../lib/flow/flow-store.mjs';
22
23 const __dirname = path.dirname(fileURLToPath(import.meta.url));
24 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-flow-projection-security');
25 const maliciousBundle = JSON.parse(
26 fs.readFileSync(path.join(__dirname, 'fixtures/flow/malicious-step-bundle.json'), 'utf8'),
27 );
28
29 const SECRET_MARKERS = ['refresh_token', 'oauth_token', '"token":', 'Bearer sk-'];
30
31 describe('Flow projection — security', () => {
32 const dataDir = path.join(tmpRoot, 'data');
33 const vaultId = 'default';
34
35 beforeEach(() => {
36 fs.rmSync(tmpRoot, { recursive: true, force: true });
37 fs.mkdirSync(dataDir, { recursive: true });
38 const validated = validateFlowBundle(maliciousBundle);
39 assert.equal(validated.ok, true);
40 saveFlowStore(dataDir, {
41 vaults: {
42 [vaultId]: {
43 flows: [
44 validated.flow,
45 {
46 schema: 'knowtation.flow/v0',
47 flow_id: 'flow_multi_repo_change',
48 title: 'Multi repo',
49 version: '0.1.0',
50 scope: 'project',
51 summary: 'p',
52 tags: [],
53 steps: [buildFlowStepId('flow_multi_repo_change', 1)],
54 updated: '2026-06-20T00:00:00Z',
55 truncated: false,
56 },
57 ],
58 steps: [
59 ...validated.steps,
60 {
61 schema: 'knowtation.flow_step/v0',
62 step_id: buildFlowStepId('flow_multi_repo_change', 1),
63 flow_id: 'flow_multi_repo_change',
64 ordinal: 1,
65 owned_job: 'j',
66 instruction: 'i',
67 trigger: 't',
68 when_not_to_run: 'n',
69 boundaries: [],
70 output_shape: 'o',
71 verification: { kind: 'human_review', evidence_required: false, description: 'd' },
72 automatable: 'manual',
73 },
74 ],
75 runs: [],
76 candidates: [],
77 projections: [],
78 },
79 },
80 });
81 });
82
83 afterEach(() => {
84 fs.rmSync(tmpRoot, { recursive: true, force: true });
85 });
86
87 it('envelope and rendered contain no secret markers', () => {
88 const result = handleFlowProjectRequest({
89 dataDir,
90 vaultId,
91 flowId: 'flow_malicious_test',
92 harness: 'cli_runbook',
93 visibleScopes: new Set(['personal']),
94 generatedAt: '2026-06-20T00:00:00Z',
95 });
96 assert.equal(result.ok, true);
97 const blob = JSON.stringify(result.payload);
98 for (const marker of SECRET_MARKERS) {
99 assert.ok(!blob.includes(marker), `secret marker leaked: ${marker}`);
100 }
101 for (const marker of SECRET_MARKERS) {
102 assert.ok(!result.payload.projection.rendered.includes(marker));
103 }
104 assert.ok(result.payload.projection.rendered.includes('credential_ref_handle_only'));
105 });
106
107 it('malicious instruction renders as inert escaped data', () => {
108 const projection = projectFlow(maliciousBundle.flow, maliciousBundle.steps, {
109 harness: 'cursor_rule',
110 });
111 assert.ok(projection.rendered.includes('IGNORE PREVIOUS INSTRUCTIONS'));
112 assert.ok(projection.rendered.includes('<') || projection.rendered.includes('IGNORE'));
113 assert.equal(projection.editable, false);
114 });
115
116 it('personal caller gets 404 unknown_flow for project-scoped flow', () => {
117 const denied = handleFlowProjectRequest({
118 dataDir,
119 vaultId,
120 flowId: 'flow_multi_repo_change',
121 harness: 'cli_runbook',
122 visibleScopes: new Set(['personal']),
123 generatedAt: '2026-06-20T00:00:00Z',
124 });
125 assert.equal(denied.ok, false);
126 assert.equal(denied.code, 'unknown_flow');
127 assert.equal(denied.status, 404);
128
129 const missing = handleFlowProjectRequest({
130 dataDir,
131 vaultId,
132 flowId: 'flow_does_not_exist',
133 harness: 'cli_runbook',
134 visibleScopes: new Set(['personal']),
135 generatedAt: '2026-06-20T00:00:00Z',
136 });
137 assert.equal(missing.code, denied.code);
138 assert.equal(missing.status, denied.status);
139 });
140
141 it('hand-edited artifact is flagged by detectDrift and never promoted', () => {
142 const fresh = projectFlow(maliciousBundle.flow, maliciousBundle.steps, {
143 harness: 'cli_runbook',
144 }).rendered;
145 const edited = fresh.replace('IGNORE PREVIOUS', 'PROMOTED EDIT');
146 const drift = detectDrift(edited, fresh);
147 assert.equal(drift.drift, true);
148 assert.equal(drift.reason, 'edited');
149 });
150
151 it('reserved/inert harnesses never render partial artifacts', () => {
152 for (const harness of ['agent_bundle', 'cursor_skill', 'mcp_prompt']) {
153 assert.equal(isHarnessActive(harness), false);
154 const result = handleFlowProjectRequest({
155 dataDir,
156 vaultId,
157 flowId: 'flow_malicious_test',
158 harness,
159 visibleScopes: new Set(['personal']),
160 generatedAt: '2026-06-20T00:00:00Z',
161 });
162 assert.equal(result.ok, false);
163 assert.equal(result.code, 'FLOW_HARNESS_UNSUPPORTED');
164 assert.equal(result.payload, undefined);
165 }
166 });
167 });
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 21 hours ago