Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 46 additions & 10 deletions .github/workflows/dependency-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
audit-main:
name: Audit Main Package
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 10

steps:
- name: Checkout repository
Expand All @@ -34,10 +34,17 @@ jobs:
cache: 'npm'

- name: Install dependencies
run: npm ci
run: npm ci --no-audit --no-fund

- name: Run npm audit (JSON output for SARIF)
run: npm audit --json > npm-audit-main.json || true
run: |
audit_args=(--package-lock-only --json --fetch-retries=0 --fetch-timeout=30000)
npm audit "${audit_args[@]}" > npm-audit-main.json || true
if jq -e '.error' npm-audit-main.json >/dev/null; then
echo "::warning::Configured npm audit endpoint failed; retrying against registry.npmjs.org"
npm audit "${audit_args[@]}" --registry=https://registry.npmjs.org \
> npm-audit-main.json || true
fi

- name: Convert npm audit to SARIF
if: always()
Expand All @@ -50,13 +57,24 @@ jobs:
sarif_file: npm-audit-main.sarif
category: npm-audit-main

- name: Run npm audit (fail on high/critical)
run: npm audit --audit-level=high
- name: Enforce npm audit (fail on high/critical)
env:
EVENT_NAME: ${{ github.event_name }}
run: |
if jq -e '.error' npm-audit-main.json >/dev/null; then
echo "::warning::npm audit advisory service unavailable after retries"
test "$EVENT_NAME" = "pull_request"
exit
fi
jq -e '
(.metadata.vulnerabilities.high == 0) and
(.metadata.vulnerabilities.critical == 0)
' npm-audit-main.json

audit-docs:
name: Audit Docs Site Package
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 10

steps:
- name: Checkout repository
Expand All @@ -70,11 +88,18 @@ jobs:
cache-dependency-path: docs-site/package-lock.json

- name: Install dependencies
run: npm ci
run: npm ci --no-audit --no-fund
working-directory: docs-site

- name: Run npm audit (JSON output for SARIF)
run: npm audit --json > npm-audit-docs.json || true
run: |
audit_args=(--package-lock-only --json --fetch-retries=0 --fetch-timeout=30000)
npm audit "${audit_args[@]}" > npm-audit-docs.json || true
if jq -e '.error' npm-audit-docs.json >/dev/null; then
echo "::warning::Configured npm audit endpoint failed; retrying against registry.npmjs.org"
npm audit "${audit_args[@]}" --registry=https://registry.npmjs.org \
> npm-audit-docs.json || true
fi
working-directory: docs-site

- name: Convert npm audit to SARIF
Expand All @@ -88,6 +113,17 @@ jobs:
sarif_file: npm-audit-docs.sarif
category: npm-audit-docs

- name: Run npm audit (fail on high/critical)
run: npm audit --audit-level=high
- name: Enforce npm audit (fail on high/critical)
env:
EVENT_NAME: ${{ github.event_name }}
run: |
if jq -e '.error' npm-audit-docs.json >/dev/null; then
echo "::warning::npm audit advisory service unavailable after retries"
test "$EVENT_NAME" = "pull_request"
exit
fi
jq -e '
(.metadata.vulnerabilities.high == 0) and
(.metadata.vulnerabilities.critical == 0)
' npm-audit-docs.json
working-directory: docs-site
33 changes: 32 additions & 1 deletion containers/bounded-execution/finite-disclosure.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ function buildSchemaNode(raw, ctx, depth) {
}
return { type: 'boolean' };
}
case 'string': {
if (Object.keys(node).length !== 1) {
return failSchema(ctx, 'string schema must have only "type"');
}
return { type: 'string' };
}
case 'enum': {
if (Object.keys(node).length !== 2 || !('values' in node)) {
return failSchema(ctx, 'enum schema must have exactly "type" and "values"');
Expand Down Expand Up @@ -250,7 +256,7 @@ function buildSchemaNode(raw, ctx, depth) {
default:
return failSchema(
ctx,
'schema node "type" must be one of: const, boolean, enum, integer, object, tuple, array, union',
'schema node "type" must be one of: const, boolean, string, enum, integer, object, tuple, array, union',
);
}
}
Expand Down Expand Up @@ -291,6 +297,8 @@ function schemaCardinality(schema) {
return 1n;
case 'boolean':
return 2n;
case 'string':
throw new Error('free-form string schemas do not have finite cardinality');
case 'enum':
return BigInt(schema.values.length);
case 'integer':
Expand Down Expand Up @@ -336,6 +344,8 @@ function cappedSchemaCardinality(schema) {
return 1n;
case 'boolean':
return 2n;
case 'string':
throw new Error('free-form string schemas do not have finite cardinality');
case 'enum':
return BigInt(schema.values.length);
case 'integer':
Expand Down Expand Up @@ -381,6 +391,8 @@ function validateValueAgainstSchema(schema, value) {
return jsonLiteralEquals(value, schema.value);
case 'boolean':
return typeof value === 'boolean';
case 'string':
return typeof value === 'string';
case 'enum':
return schema.values.some((candidate) => jsonLiteralEquals(value, candidate));
case 'integer':
Expand Down Expand Up @@ -429,6 +441,7 @@ function canonicalizeSchemaValue(schema, value) {
case 'const':
return JSON.stringify(schema.value);
case 'boolean':
case 'string':
case 'enum':
case 'integer':
return JSON.stringify(value);
Expand All @@ -452,6 +465,23 @@ function canonicalizeSchemaValue(schema, value) {
}
}

function schemaContainsFreeformString(schema) {
switch (schema.type) {
case 'string':
return true;
case 'object':
return schema.fields.some((field) => schemaContainsFreeformString(field.schema));
case 'tuple':
return schema.items.some(schemaContainsFreeformString);
case 'array':
return schemaContainsFreeformString(schema.items);
case 'union':
return schema.variants.some((variant) => schemaContainsFreeformString(variant.schema));
default:
return false;
}
}

// ── Strict JSON parsing (no `JSON.parse`) ────────────────────────────────────

const MAX_JSON_PARSE_DEPTH = 32;
Expand Down Expand Up @@ -683,6 +713,7 @@ module.exports = {
informationChargeForSchema,
validateValueAgainstSchema,
canonicalizeSchemaValue,
schemaContainsFreeformString,
strictParseJson,
validateEnclaveScriptRequest,
canonicalSuccessJson,
Expand Down
12 changes: 7 additions & 5 deletions containers/bounded-execution/sensitivity-policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@
* budgets — server-side mirror of `ENCLAVE_SENSITIVITY_RUN_BITS` in
* `src/types/enclave-options.ts`.
*
* `null` means "unmetered": `public` still runs through the same finite
* schema/result validation and operational limits (`maxInvocations`,
* timeouts, sandboxing) as every other category, but its responses are not
* debited against a confidentiality ledger. `sealed` is `0`, which —
* `null` means "unmetered": `public` still requires finite schemas, while
* `trusted` may additionally use free-form string schema nodes. Both retain
* schema/result validation and operational limits (`maxInvocations`, timeouts,
* sandboxing), but their responses are not debited against a confidentiality
* ledger. `sealed` is `0`, which —
* because every accepted query's minimum charge is 5 bits (1 status bit +
* 4 timing bits) — always exceeds the remaining balance, so a `sealed`
* repository can never fund a single query and therefore never copies a
* seed or launches Python.
*/
const ENCLAVE_SENSITIVITIES = ['public', 'internal', 'confidential', 'sealed'];
const ENCLAVE_SENSITIVITIES = ['trusted', 'public', 'internal', 'confidential', 'sealed'];

const ENCLAVE_SENSITIVITY_RUN_BITS = {
trusted: null,
public: null,
internal: 64,
confidential: 8,
Expand Down
2 changes: 1 addition & 1 deletion containers/enclave/agent-entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ def build_prompt(task: str, schema_text: str) -> str:
else:
output_contract = (
"Before finishing, use a shell command to replace /awf/out with exactly one JSON "
"value conforming to this finite schema. Do not write a Markdown fence, "
"value conforming to this structured response schema. Do not write a Markdown fence, "
"explanation, surrounding text, or repeated schema to /awf/out. Your "
f"conversational response is not the result channel:\n{schema_text}\n"
)
Expand Down
9 changes: 5 additions & 4 deletions containers/enclave/mcp-server/mcp-protocol.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ function canonicalToolError() {

const FINITE_SCHEMA_INPUT = Object.freeze({
type: 'object',
description: 'An AWF finite-disclosure schema (const, boolean, enum, integer, object, tuple, array, or union).',
description:
'An AWF structured response schema (const, boolean, string for trusted repositories, enum, integer, object, tuple, array, or union).',
});

const TOOL = Object.freeze({
name: TOOL_NAME,
description: 'Run a bounded script against one configured private repository and return one finite value.',
description: 'Run a bounded script against one configured repository and return one structured value.',
inputSchema: Object.freeze({
type: 'object',
properties: Object.freeze({
Expand Down Expand Up @@ -61,8 +62,8 @@ const TOOL = Object.freeze({
const AGENT_TOOL = Object.freeze({
name: AGENT_TOOL_NAME,
description:
'Run a bounded, single-use agent enclave against one configured private repository and return '
+ 'one finite value.',
'Run a bounded, single-use agent enclave against one configured repository and return '
+ 'one structured value.',
inputSchema: Object.freeze({
type: 'object',
properties: Object.freeze({
Expand Down
10 changes: 9 additions & 1 deletion containers/enclave/script-executor/executor-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
canonicalSuccessJson,
parseAndValidateFiniteOutput,
informationChargeForSchema,
schemaContainsFreeformString,
validateEnclaveScriptRequest,
} = require('../../bounded-execution/finite-disclosure');
const { createEnclaveInformationBudgetLedger } = require('../../bounded-execution/sensitivity-ledger');
Expand Down Expand Up @@ -133,12 +134,19 @@ function createExecutorHandler(params) {
await rejectBeforeExecution('repo-not-allowed', privateRepo);
return;
}
if (schemaContainsFreeformString(schema) && seed.sensitivity !== 'trusted') {
await rejectBeforeExecution(
'trusted-schema-required',
`repo=${privateRepo} sensitivity=${seed.sensitivity}`,
);
return;
}

// Compute and debit the charge for THIS invocation's schema *before*
// copying a seed or launching Python. Every invocation may declare a
// different schema; there is no separate per-query cap — only whether
// this charge fits the repository's remaining run balance.
const charge = informationChargeForSchema(schema);
const charge = seed.sensitivity === 'trusted' ? 0 : informationChargeForSchema(schema);
if (!ledger.tryDebit(repoKey, charge, executorKind)) {
await rejectBeforeExecution('bit-budget-exhausted', `repo=${privateRepo} charge=${charge}`);
return;
Expand Down
19 changes: 19 additions & 0 deletions docs/awf-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1914,6 +1914,25 @@ in force.

Script and agent calls debit the same live per-repository balance and share one AWF-owned admission lane. Switching executor kinds never resets or forks the ledger.

Repository sensitivity selects the response schema and per-run disclosure policy:

| Sensitivity | Per-run budget | Response schema |
|-------------|----------------|-----------------|
| `trusted` | Unmetered | Structured schemas, including free-form `string` nodes |
| `public` | Unmetered | Finite schemas only |
| `internal` | 64 bits | Finite schemas only |
| `confidential` | 8 bits | Finite schemas only |
| `sealed` | 0 bits | No invocation can be admitted |

The `trusted` class is intended only for repositories whose content may be
returned to the primary agent without confidentiality accounting. It permits
an exact `{ "type": "string" }` schema node at any otherwise valid schema
position. Strings remain bounded by `maxOutputBytes` and the global 8192-byte
result ceiling. The schema remains strict and structured: floats, optional
fields, extra properties, `$ref`, recursion, regex schemas, and untagged unions
are unsupported. Every other sensitivity rejects a schema containing a
free-form `string` node before launching an enclave.

`enclave_run_agent` necessarily sends repository-derived content to the configured model provider through the dedicated API proxy. The ledger bounds what the **calling agent** learns; it does not bound what the **provider** sees.

### 14.5 Validation coverage
Expand Down
1 change: 1 addition & 0 deletions docs/awf-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1160,6 +1160,7 @@
"sensitivity": {
"type": "string",
"enum": [
"trusted",
"public",
"internal",
"confidential",
Expand Down
1 change: 1 addition & 0 deletions src/awf-config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1160,6 +1160,7 @@
"sensitivity": {
"type": "string",
"enum": [
"trusted",
"public",
"internal",
"confidential",
Expand Down
62 changes: 62 additions & 0 deletions src/bounded-execution/finite-disclosure-string.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as path from 'path';
import {
canonicalizeSchemaValue,
parseAndValidateFiniteOutput,
schemaCardinality,
schemaContainsFreeformString,
validateSchema,
validateValueAgainstSchema,
} from './finite-disclosure';

/* eslint-disable @typescript-eslint/no-require-imports */
const containerProtocol = require(
path.join(__dirname, '..', '..', 'containers', 'bounded-execution', 'finite-disclosure.js'),
);
/* eslint-enable @typescript-eslint/no-require-imports */

const structuredStringSchema = {
type: 'object',
fields: {
action: { type: 'enum', values: ['dispatch', 'ignore'] },
rationale: { type: 'string' },
},
};

describe.each([
['host', {
canonicalizeSchemaValue,
parseAndValidateFiniteOutput,
schemaCardinality,
schemaContainsFreeformString,
validateSchema,
validateValueAgainstSchema,
}],
['container', containerProtocol],
])('trusted string schema parity: %s', (_name, protocol) => {
it('parses, detects, validates, and canonicalizes a structured free-form string', () => {
const validation = protocol.validateSchema(structuredStringSchema);
expect(validation.valid).toBe(true);
if (!validation.valid) return;

const value = { rationale: 'Issue body can be summarized freely.', action: 'dispatch' };
expect(protocol.schemaContainsFreeformString(validation.schema)).toBe(true);
expect(protocol.validateValueAgainstSchema(validation.schema, value)).toBe(true);
expect(protocol.canonicalizeSchemaValue(validation.schema, value)).toBe(
'{"action":"dispatch","rationale":"Issue body can be summarized freely."}',
);
expect(protocol.parseAndValidateFiniteOutput(JSON.stringify(value), validation.schema)).toEqual({
ok: true,
canonical: '{"action":"dispatch","rationale":"Issue body can be summarized freely."}',
});
});

it('keeps string schemas exact and outside finite-cardinality accounting', () => {
expect(protocol.validateSchema({ type: 'string', maxLength: 10 }).valid).toBe(false);
const validation = protocol.validateSchema({ type: 'string' });
expect(validation.valid).toBe(true);
if (!validation.valid) return;
expect(() => protocol.schemaCardinality(validation.schema)).toThrow(
'free-form string schemas do not have finite cardinality',
);
});
});
Loading
Loading