Skip to content
Open
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
25 changes: 25 additions & 0 deletions marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,31 @@
"visualization"
],
"icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/obsidian/icon.png"
},
{
"name": "aifeed",
"source": "./plugins/aifeed",
"description": "Signed content permissions for AI agents: verify a site's AIFeed manifest (Ed25519 + DNS anchor), fetch token-budgeted markdown, and decide whether a use such as retrieval, training, or quoting is allowed.",
"description_i18n": {
"en": "Signed content permissions for AI agents: verify a site's AIFeed manifest (Ed25519 + DNS anchor), fetch token-budgeted markdown, and decide whether a use such as retrieval, training, or quoting is allowed.",
"zh-CN": "面向 AI 智能体的签名内容许可:验证站点的 AIFeed manifest(Ed25519 + DNS 锚点)、按 token 预算抓取 markdown,并判断检索、训练、引用等用途是否被允许。"
},
"version": "1.0.0-draft.1",
"author": {
"name": "denyn1",
"url": "https://aifeed.md"
},
"category": "developer-tools",
"keywords": [
"aifeed",
"content-permissions",
"robots.txt",
"ed25519",
"mcp",
"verification",
"markdown"
],
"icon": "https://aifeed.md/logo.svg"
}
]
}
16 changes: 16 additions & 0 deletions plugins/aifeed/.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"mcpServers": {
"aifeed": {
"type": "stdio",
"command": "node",
"args": [
"${ZCODE_PLUGIN_ROOT}/engine/server.js"
],
"env": {
"AIFEED_MCP_ALLOW_PRIVATE": "${user_config.allow_private}"
},
"enabled": true,
"timeoutMs": 60000
}
}
}
35 changes: 35 additions & 0 deletions plugins/aifeed/.zcode-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "aifeed",
"description": "Signed content permissions for AI agents: verify a site's AIFeed manifest (Ed25519 + DNS anchor), fetch token-budgeted markdown, and decide whether a use such as retrieval, training, or quoting is allowed.",
"description_i18n": {
"en": "Signed content permissions for AI agents: verify a site's AIFeed manifest (Ed25519 + DNS anchor), fetch token-budgeted markdown, and decide whether a use such as retrieval, training, or quoting is allowed.",
"zh-CN": "面向 AI 智能体的签名内容许可:验证站点的 AIFeed manifest(Ed25519 + DNS 锚点)、按 token 预算抓取 markdown,并判断检索、训练、引用等用途是否被允许。"
},
"version": "1.0.0-draft.1",
"author": {
"name": "denyn1",
"url": "https://aifeed.md"
},
"homepage": "https://aifeed.md",
"repository": "https://github.com/denyn1/aifeed-protocol",
"license": "MIT",
"keywords": [
"aifeed",
"content-permissions",
"robots.txt",
"ed25519",
"mcp",
"verification",
"markdown"
],
"skills": "skills",
"mcpServers": ".mcp.json",
"userConfig": {
"allow_private": {
"title": "Allow loopback URLs",
"description": "Set to 1 to allow http:// loopback origins during local testing (default 0).",
"type": "string",
"default": "0"
}
}
}
21 changes: 21 additions & 0 deletions plugins/aifeed/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 AIFeed Protocol Contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
43 changes: 43 additions & 0 deletions plugins/aifeed/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# AIFeed — signed content permissions for agents

[AIFeed](https://aifeed.md) is an open standard (MIT code, CC BY 4.0 specs) for signed
content permissions on the AI web. A site publishes a manifest at `/.well-known/ai.json`
signed with Ed25519, anchors the public key in a DNS `_aifeed` TXT record, and can revoke
it. Agents verify the chain instead of trusting an unsigned, unrevocable text file.

This plugin brings that workflow into ZCode:

- **MCP server** (`aifeed`, stdio, bundled under `engine/`) with six tools:
- `verify_manifest` — signature + DNS anchor + VERIFIED/UNVERIFIED
- `fetch_aifeed` — token-budgeted AIFeed Markdown/MAKO with permissions and an optional page-signature check
- `list_assets` — images, videos, and documents a signed page declares
- `verify_asset` — download a declared asset and check size/sha-256
- `select_index` — rank signed delta-index entries within page/token budgets
- `decide_usage` — is this use (retrieval, training, quoting, ...) allowed?
- **Skill** (`aifeed`) — verify-first and publish flows, so the agent checks permissions
before fetching and never assumes allow.

## Network, dependencies, and side effects

- The engine is **zero-dependency** (Node standard library only) and runs from this
plugin directory; no npm install and no runtime downloads.
- Network: HTTPS requests to the domain you ask about (manifest, content, assets, DNS
TXT lookups). No telemetry; nothing is sent to the plugin author.
- Writes: none. The plugin never writes to your project, home directory, or ZCode data.
- `http://` loopback origins are allowed only when the `allow_private` option is set to
`1` (local testing).

## Requirements

Node.js 20 or newer (`node` on `PATH`). The engine checks HTTPS-only origins by default.

## Links

- Website and live signed demo origins: https://aifeed.md
- Source, specs, conformance vectors: https://github.com/denyn1/aifeed-protocol
- npm packages: `aifeed` (CLI), `@aifeed/verify` (SDK), `aifeed-mcp-server`, `@aifeed/frameworks`

Honest limits: the specs are a draft, there is no external cryptographic review yet, and
an origin + DNS compromise is undetectable on first contact.

MIT.
37 changes: 37 additions & 0 deletions plugins/aifeed/README_CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# AIFeed —— 面向 AI 智能体的签名内容许可

[AIFeed](https://aifeed.md) 是一个面向 AI 网络的签名内容许可开放标准(代码 MIT,规范 CC BY 4.0)。
站点在 `/.well-known/ai.json` 发布使用 Ed25519 签名的 manifest,公钥锚定在 DNS
`_aifeed` TXT 记录中,并支持撤销。智能体先验证这条链路,而不是去信任一个未签名、不可撤销的文本文件。

本插件把该流程带入 ZCode:

- **MCP 服务器**(`aifeed`,stdio,随包内置在 `engine/`),提供六个工具:
- `verify_manifest` —— 签名 + DNS 锚点 + VERIFIED/UNVERIFIED
- `fetch_aifeed` —— 按 token 预算获取 AIFeed Markdown/MAKO,附带许可信息与可选的页面签名校验
- `list_assets` —— 列出签名页面声明的图片、视频与文档
- `verify_asset` —— 下载声明的资源并校验大小/sha-256
- `select_index` —— 在页面/token 预算内对签名增量索引条目排序
- `decide_usage` —— 判断某项用途(检索、训练、引用等)是否被允许
- **技能**(`aifeed`)—— 先验证的工作流与发布流程,让智能体在抓取前检查许可,绝不默认放行。

## 网络、依赖与副作用

- 引擎**零依赖**(仅使用 Node 标准库),直接从插件目录运行;无需 npm 安装,也不会在运行时下载。
- 网络:向你询问的域名发起 HTTPS 请求(manifest、内容、资源、DNS TXT 查询)。无遥测,不会向插件作者发送任何数据。
- 写入:无。插件不会写入你的项目、主目录或 ZCode 数据。
- 仅当 `allow_private` 选项设为 `1` 时,才允许 `http://` 回环源(仅用于本地测试)。

## 要求

Node.js 20 或更高版本(`node` 在 `PATH` 中)。引擎默认只抓取 `https://` 源。

## 链接

- 官网与可直接验证的签名演示源站:https://aifeed.md
- 源码、规范、一致性向量:https://github.com/denyn1/aifeed-protocol
- npm 包:`aifeed`(CLI)、`@aifeed/verify`(SDK)、`aifeed-mcp-server`、`@aifeed/frameworks`

诚实的限制:规范仍为草案,尚无外部密码学审计;源站 + DNS 同时被攻陷时,首次接触无法识别。

MIT。
209 changes: 209 additions & 0 deletions plugins/aifeed/engine/lib/bundle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
'use strict';

const fs = require('node:fs');
const path = require('node:path');
const nodeCrypto = require('node:crypto');
const cryptoLib = require('./crypto');
const { parseStrict } = require('./parse');
const { sha256Base64 } = require('./digest');
const { verifyAll } = require('./validate');

const BUNDLE_VERSION = '0.1';
const BUNDLE_STALE_HOURS = 168;

function isoSecond(date) {
return new Date(date).toISOString().replace(/\.\d{3}Z$/, 'Z');
}

function fileEntry(rootDir, relativePath) {
const bytes = fs.readFileSync(path.join(rootDir, relativePath));
return {
path: relativePath,
'sha-256': sha256Base64(bytes),
size: bytes.length
};
}

function createBundle(options) {
const {
sourceDir,
outDir,
domain,
privateKey = null,
keyId = null,
revocationFile = null,
now = new Date()
} = options;

const createdAt = isoSecond(now);
const manifestDir = path.join(outDir, 'manifest');
fs.mkdirSync(manifestDir, { recursive: true });
fs.mkdirSync(path.join(outDir, 'governance'), { recursive: true });
fs.mkdirSync(path.join(outDir, 'revocation'), { recursive: true });

fs.copyFileSync(path.join(sourceDir, 'ai.json'), path.join(manifestDir, 'ai.json'));
fs.copyFileSync(path.join(sourceDir, 'ai-signature.json'), path.join(manifestDir, 'ai-signature.json'));
fs.writeFileSync(
path.join(manifestDir, 'fetch-metadata.json'),
JSON.stringify({
url: 'https://' + domain + '/.well-known/ai.json',
fetched_at: createdAt,
source: 'local-bundle',
content_digest: null,
tls_fingerprint: null
}, null, 2) + '\n'
);

const files = [
fileEntry(outDir, 'manifest/ai.json'),
fileEntry(outDir, 'manifest/ai-signature.json'),
fileEntry(outDir, 'manifest/fetch-metadata.json')
];

if (revocationFile) {
const target = path.join('revocation', domain + '.json');
fs.copyFileSync(revocationFile, path.join(outDir, target));
files.push(fileEntry(outDir, target));
}

const bundleManifest = {
version: BUNDLE_VERSION,
created_at: createdAt,
domain,
files
};

if (privateKey) {
const signatureBytes = cryptoLib.signBundle(privateKey, bundleManifest);
bundleManifest.bundler = {
fingerprint: cryptoLib.fingerprintOf(nodeCrypto.createPublicKey(privateKey)),
key_id: keyId,
algorithm: 'ed25519',
signature: cryptoLib.encodeSignature(signatureBytes)
};
}

fs.writeFileSync(
path.join(outDir, 'BUNDLE-MANIFEST.json'),
JSON.stringify(bundleManifest, null, 2) + '\n'
);
return bundleManifest;
}

function verifyBundle(options) {
const { bundleDir, now = new Date(), bundlerPublicKeyValue = null } = options;
const errors = [];
const warnings = [];

let bundleManifest;
try {
bundleManifest = parseStrict(
fs.readFileSync(path.join(bundleDir, 'BUNDLE-MANIFEST.json'), 'utf8'),
{ integersOnly: true }
);
} catch (error) {
return {
result: 'UNVERIFIED',
errors: [{ code: error.code || 'bundle_manifest_invalid', message: error.message }],
warnings,
files: 0,
created_at: null,
age_hours: null
};
}

if (!Array.isArray(bundleManifest.files) || typeof bundleManifest.domain !== 'string' || typeof bundleManifest.created_at !== 'string') {
return {
result: 'UNVERIFIED',
errors: [{ code: 'bundle_manifest_invalid', message: 'missing required bundle manifest fields' }],
warnings,
files: 0,
created_at: null,
age_hours: null
};
}

for (const entry of bundleManifest.files) {
if (!entry || typeof entry.path !== 'string' || typeof entry['sha-256'] !== 'string') {
errors.push({ code: 'bundle_manifest_invalid', message: 'invalid file entry in bundle manifest' });
continue;
}
if (path.isAbsolute(entry.path) || entry.path.split(path.sep).includes('..') || entry.path.split('/').includes('..')) {
errors.push({ code: 'bundle_manifest_invalid', message: 'unsafe file path in bundle manifest: ' + entry.path });
continue;
}
const resolved = path.resolve(bundleDir, entry.path);
const root = path.resolve(bundleDir) + path.sep;
if (!resolved.startsWith(root)) {
errors.push({ code: 'bundle_manifest_invalid', message: 'file path escapes bundle directory: ' + entry.path });
continue;
}
try {
const bytes = fs.readFileSync(resolved);
if (typeof entry.size === 'number' && bytes.length !== entry.size) {
errors.push({ code: 'bundle_file_mismatch', message: 'size mismatch for ' + entry.path });
}
if (sha256Base64(bytes) !== entry['sha-256']) {
errors.push({ code: 'bundle_file_mismatch', message: 'sha-256 mismatch for ' + entry.path });
}
} catch (error) {
errors.push({ code: 'bundle_file_missing', message: 'cannot read ' + entry.path + ': ' + error.message });
}
}

if (bundleManifest.bundler) {
if (bundlerPublicKeyValue) {
try {
const publicKey = cryptoLib.decodePublicKey(bundlerPublicKeyValue);
const { bundler, ...signedManifest } = bundleManifest;
const signatureBytes = cryptoLib.decodeSignature(bundler.signature);
if (!cryptoLib.verifyBundle(publicKey, signedManifest, signatureBytes)) {
errors.push({ code: 'bundle_signature_invalid', message: 'bundler signature verification failed' });
}
} catch (error) {
errors.push({ code: 'bundle_signature_invalid', message: error.message });
}
} else {
warnings.push({ code: 'bundle_signature_unverified', message: 'bundler public key not provided; signature not checked' });
}
} else {
warnings.push({ code: 'bundle_unsigned', message: 'bundle has no bundler signature' });
}

let manifestResult = null;
try {
const manifestPath = path.join(bundleDir, 'manifest', 'ai.json');
manifestResult = verifyAll({
manifestText: fs.readFileSync(manifestPath, 'utf8'),
manifestBytes: fs.readFileSync(manifestPath),
signatureText: fs.readFileSync(path.join(bundleDir, 'manifest', 'ai-signature.json'), 'utf8'),
domain: bundleManifest.domain,
now
});
errors.push(...manifestResult.errors);
warnings.push(...manifestResult.warnings);
} catch (error) {
errors.push({ code: 'bundle_manifest_missing', message: error.message });
}

const createdAt = Date.parse(bundleManifest.created_at);
const ageHours = Number.isFinite(createdAt) ? Math.max(0, (now.getTime() - createdAt) / 3600000) : null;
if (ageHours !== null && ageHours > BUNDLE_STALE_HOURS && errors.length === 0) {
errors.push({
code: 'bundle_stale',
message: 'bundle is older than ' + BUNDLE_STALE_HOURS + ' hours; trust reduced'
});
}

return {
result: errors.length === 0 ? 'VERIFIED' : 'UNVERIFIED',
errors,
warnings,
files: bundleManifest.files.length,
created_at: bundleManifest.created_at,
age_hours: ageHours === null ? null : Math.round(ageHours * 100) / 100,
manifest_result: manifestResult ? manifestResult.result : null
};
}

module.exports = { BUNDLE_VERSION, BUNDLE_STALE_HOURS, createBundle, verifyBundle };
Loading