@flowcodex/core 0.3.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/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/index-LbxYtxxS.d.ts +560 -0
- package/dist/index.d.ts +995 -0
- package/dist/index.js +3840 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/index.d.ts +1 -0
- package/dist/kernel/index.js +551 -0
- package/dist/kernel/index.js.map +1 -0
- package/package.json +39 -0
- package/src/agent/agent-loop.ts +254 -0
- package/src/agent/context.ts +99 -0
- package/src/agent/conversation-state.ts +44 -0
- package/src/agent/provider-runner.ts +241 -0
- package/src/agent/system-prompt-builder.ts +193 -0
- package/src/execution/compactor.ts +256 -0
- package/src/execution/index.ts +7 -0
- package/src/execution/output-serializer.ts +90 -0
- package/src/execution/schema-validator.ts +124 -0
- package/src/execution/tool-executor.ts +276 -0
- package/src/execution/tool-registry.ts +104 -0
- package/src/index.ts +215 -0
- package/src/infrastructure/catalog-parser.ts +218 -0
- package/src/infrastructure/index.ts +16 -0
- package/src/infrastructure/path-resolver.ts +123 -0
- package/src/infrastructure/provider-factory.ts +116 -0
- package/src/infrastructure/provider-presets.ts +19 -0
- package/src/infrastructure/retry-policy.ts +50 -0
- package/src/infrastructure/secret-scrubber.ts +67 -0
- package/src/infrastructure/token-counter.ts +156 -0
- package/src/infrastructure/tracer.ts +23 -0
- package/src/kernel/container.ts +166 -0
- package/src/kernel/events.ts +323 -0
- package/src/kernel/index.ts +18 -0
- package/src/kernel/pipeline.ts +152 -0
- package/src/kernel/run-controller.ts +85 -0
- package/src/kernel/tokens.ts +21 -0
- package/src/security/index.ts +13 -0
- package/src/security/permission-policy.ts +273 -0
- package/src/session/audit-log.ts +201 -0
- package/src/session/auth-service.ts +178 -0
- package/src/session/index.ts +26 -0
- package/src/session/secret-vault.ts +183 -0
- package/src/session/session-store.ts +339 -0
- package/src/session/types.ts +100 -0
- package/src/types/blocks.ts +56 -0
- package/src/types/context.ts +54 -0
- package/src/types/errors.ts +359 -0
- package/src/types/index.ts +34 -0
- package/src/types/provider.ts +58 -0
- package/src/types/tool.ts +39 -0
- package/src/utils/error.ts +3 -0
- package/src/utils/fs.ts +185 -0
- package/src/utils/image-resize.ts +76 -0
- package/src/utils/ssrf-guard.ts +133 -0
- package/src/utils/ulid.ts +72 -0
- package/src/utils/version-check.ts +59 -0
- package/tests/agent-loop.test.ts +490 -0
- package/tests/audit-log.test.ts +199 -0
- package/tests/auth-service.test.ts +170 -0
- package/tests/blocks.test.ts +79 -0
- package/tests/catalog-parser.test.ts +174 -0
- package/tests/compactor.test.ts +180 -0
- package/tests/container.test.ts +224 -0
- package/tests/conversation-state.test.ts +75 -0
- package/tests/errors.test.ts +429 -0
- package/tests/events-v021.test.ts +60 -0
- package/tests/events-v022.test.ts +75 -0
- package/tests/events.test.ts +340 -0
- package/tests/fixtures/large-image.png +0 -0
- package/tests/fixtures/small-image.png +0 -0
- package/tests/fs-utils.test.ts +164 -0
- package/tests/image-resize.test.ts +51 -0
- package/tests/output-serializer.test.ts +79 -0
- package/tests/path-resolver.test.ts +91 -0
- package/tests/permission-policy.test.ts +174 -0
- package/tests/pipeline.test.ts +193 -0
- package/tests/provider-factory.test.ts +245 -0
- package/tests/provider-runner.test.ts +535 -0
- package/tests/retry-policy.test.ts +104 -0
- package/tests/run-controller.test.ts +115 -0
- package/tests/sanity.test.ts +26 -0
- package/tests/schema-validator.test.ts +109 -0
- package/tests/secret-scrubber.test.ts +133 -0
- package/tests/secret-vault.test.ts +130 -0
- package/tests/session-store.test.ts +429 -0
- package/tests/ssrf-guard.test.ts +112 -0
- package/tests/system-prompt-builder.test.ts +116 -0
- package/tests/token-counter.test.ts +163 -0
- package/tests/tokens.test.ts +42 -0
- package/tests/tool-executor.test.ts +452 -0
- package/tests/tool-registry.test.ts +143 -0
- package/tests/tracer.test.ts +32 -0
- package/tests/ulid.test.ts +53 -0
- package/tests/version-check.test.ts +57 -0
- package/tsconfig.json +11 -0
- package/tsup.config.ts +16 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export type NextFn<T> = (value: T) => Promise<T>;
|
|
2
|
+
export type MiddlewareHandler<T> = (value: T, next: NextFn<T>) => Promise<T>;
|
|
3
|
+
|
|
4
|
+
export type PipelineErrorPolicy = 'rethrow' | 'swallow';
|
|
5
|
+
|
|
6
|
+
export interface PipelineErrorEvent {
|
|
7
|
+
middleware: string;
|
|
8
|
+
owner?: string | undefined;
|
|
9
|
+
err: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type PipelineErrorHandler = (
|
|
13
|
+
ev: PipelineErrorEvent,
|
|
14
|
+
) => PipelineErrorPolicy | Promise<PipelineErrorPolicy>;
|
|
15
|
+
|
|
16
|
+
export interface Middleware<T> {
|
|
17
|
+
name: string;
|
|
18
|
+
handler: MiddlewareHandler<T>;
|
|
19
|
+
owner?: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PipelineOptions {
|
|
23
|
+
optional?: boolean | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ReadonlyPipeline<T> {
|
|
27
|
+
readonly size: number;
|
|
28
|
+
list(): readonly string[];
|
|
29
|
+
run(input: T): Promise<T>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class Pipeline<T> {
|
|
33
|
+
private readonly chain: Middleware<T>[] = [];
|
|
34
|
+
private errorHandler?: PipelineErrorHandler | undefined;
|
|
35
|
+
|
|
36
|
+
setErrorHandler(handler: PipelineErrorHandler | undefined): this {
|
|
37
|
+
this.errorHandler = handler;
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
use(mw: Middleware<T> | Middleware<unknown>): this {
|
|
42
|
+
this.ensureUnique(mw.name);
|
|
43
|
+
this.chain.push(mw as Middleware<T>);
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
prepend(mw: Middleware<T>): this {
|
|
48
|
+
this.ensureUnique(mw.name);
|
|
49
|
+
this.chain.unshift(mw);
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
insertAt(index: number, mw: Middleware<T>): this {
|
|
54
|
+
this.ensureUnique(mw.name);
|
|
55
|
+
const idx = Math.max(0, Math.min(index, this.chain.length));
|
|
56
|
+
this.chain.splice(idx, 0, mw);
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
insertBefore(target: string, mw: Middleware<T>, opts?: PipelineOptions): this {
|
|
61
|
+
this.ensureUnique(mw.name);
|
|
62
|
+
const idx = this.indexOf(target, opts?.optional);
|
|
63
|
+
if (idx === -1) return this;
|
|
64
|
+
this.chain.splice(idx, 0, mw);
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
insertAfter(target: string, mw: Middleware<T>, opts?: PipelineOptions): this {
|
|
69
|
+
this.ensureUnique(mw.name);
|
|
70
|
+
const idx = this.indexOf(target, opts?.optional);
|
|
71
|
+
if (idx === -1) return this;
|
|
72
|
+
this.chain.splice(idx + 1, 0, mw);
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
replace(target: string, mw: Middleware<T>, opts?: PipelineOptions): this {
|
|
77
|
+
if (mw.name !== target) this.ensureUnique(mw.name);
|
|
78
|
+
const idx = this.indexOf(target, opts?.optional);
|
|
79
|
+
if (idx === -1) return this;
|
|
80
|
+
this.chain[idx] = mw;
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
remove(name: string, opts?: PipelineOptions): this {
|
|
85
|
+
const idx = this.indexOf(name, opts?.optional);
|
|
86
|
+
if (idx === -1) return this;
|
|
87
|
+
this.chain.splice(idx, 1);
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
list(): readonly string[] {
|
|
92
|
+
return this.chain.map((m) => m.name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
size(): number {
|
|
96
|
+
return this.chain.length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
asReadonly(): ReadonlyPipeline<T> {
|
|
100
|
+
const self = this;
|
|
101
|
+
return Object.freeze({
|
|
102
|
+
get size() {
|
|
103
|
+
return self.size();
|
|
104
|
+
},
|
|
105
|
+
list() {
|
|
106
|
+
return Object.freeze(self.list());
|
|
107
|
+
},
|
|
108
|
+
run(input: T) {
|
|
109
|
+
return self.run(input);
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async run(input: T): Promise<T> {
|
|
115
|
+
let index = -1;
|
|
116
|
+
const chain = this.chain;
|
|
117
|
+
const errorHandler = this.errorHandler;
|
|
118
|
+
|
|
119
|
+
const dispatch = async (i: number, value: T): Promise<T> => {
|
|
120
|
+
if (i <= index) {
|
|
121
|
+
throw new Error(`Pipeline: next() called multiple times in "${chain[index]?.name}"`);
|
|
122
|
+
}
|
|
123
|
+
index = i;
|
|
124
|
+
const mw = chain[i];
|
|
125
|
+
if (!mw) return value;
|
|
126
|
+
try {
|
|
127
|
+
return await mw.handler(value, (v) => dispatch(i + 1, v));
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (!errorHandler) throw err;
|
|
130
|
+
const policy = await errorHandler({ middleware: mw.name, owner: mw.owner, err });
|
|
131
|
+
if (policy === 'rethrow') throw err;
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return dispatch(0, input);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private indexOf(name: string, optional = false): number {
|
|
140
|
+
const idx = this.chain.findIndex((m) => m.name === name);
|
|
141
|
+
if (idx === -1 && !optional) {
|
|
142
|
+
throw new Error(`Pipeline: middleware "${name}" not found`);
|
|
143
|
+
}
|
|
144
|
+
return idx;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private ensureUnique(name: string): void {
|
|
148
|
+
if (this.chain.some((m) => m.name === name)) {
|
|
149
|
+
throw new Error(`Pipeline: middleware "${name}" already registered`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { toErrorMessage } from '../utils/error.js';
|
|
2
|
+
|
|
3
|
+
export interface RunControllerOptions {
|
|
4
|
+
parentSignal?: AbortSignal | undefined;
|
|
5
|
+
errorSink?: (err: unknown, where: string) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class RunController {
|
|
9
|
+
private readonly ctrl = new AbortController();
|
|
10
|
+
private readonly hooks: Array<() => void | Promise<void>> = [];
|
|
11
|
+
private disposed = false;
|
|
12
|
+
private hooksDrained = false;
|
|
13
|
+
private readonly errorSink: (err: unknown, where: string) => void;
|
|
14
|
+
|
|
15
|
+
constructor(opts: RunControllerOptions = {}) {
|
|
16
|
+
this.errorSink =
|
|
17
|
+
opts.errorSink ??
|
|
18
|
+
((err, where) => {
|
|
19
|
+
console.warn(JSON.stringify({
|
|
20
|
+
level: 'warn',
|
|
21
|
+
event: 'run.cleanup_hook_failed',
|
|
22
|
+
where,
|
|
23
|
+
message: toErrorMessage(err),
|
|
24
|
+
timestamp: new Date().toISOString(),
|
|
25
|
+
}));
|
|
26
|
+
});
|
|
27
|
+
if (opts.parentSignal) {
|
|
28
|
+
const parent = opts.parentSignal;
|
|
29
|
+
if (parent.aborted) {
|
|
30
|
+
this.ctrl.abort(parent.reason);
|
|
31
|
+
} else {
|
|
32
|
+
const onParentAbort = () => this.ctrl.abort(parent.reason);
|
|
33
|
+
parent.addEventListener('abort', onParentAbort, { once: true });
|
|
34
|
+
this.onAbort(() => parent.removeEventListener('abort', onParentAbort));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
this.ctrl.signal.addEventListener(
|
|
38
|
+
'abort',
|
|
39
|
+
() => {
|
|
40
|
+
void this.runHooks();
|
|
41
|
+
},
|
|
42
|
+
{ once: true },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get signal(): AbortSignal {
|
|
47
|
+
return this.ctrl.signal;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get aborted(): boolean {
|
|
51
|
+
return this.ctrl.signal.aborted;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
abort(reason?: unknown): void {
|
|
55
|
+
if (this.ctrl.signal.aborted) return;
|
|
56
|
+
this.ctrl.abort(reason);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onAbort(fn: () => void | Promise<void>): () => void {
|
|
60
|
+
this.hooks.push(fn);
|
|
61
|
+
return () => {
|
|
62
|
+
const idx = this.hooks.indexOf(fn);
|
|
63
|
+
if (idx !== -1) this.hooks.splice(idx, 1);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async dispose(): Promise<void> {
|
|
68
|
+
if (this.disposed) return;
|
|
69
|
+
this.disposed = true;
|
|
70
|
+
await this.runHooks();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async runHooks(): Promise<void> {
|
|
74
|
+
if (this.hooksDrained) return;
|
|
75
|
+
this.hooksDrained = true;
|
|
76
|
+
const snapshot = this.hooks.splice(0, this.hooks.length).reverse();
|
|
77
|
+
for (const hook of snapshot) {
|
|
78
|
+
try {
|
|
79
|
+
await hook();
|
|
80
|
+
} catch (err) {
|
|
81
|
+
this.errorSink(err, 'RunController.dispose');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Token } from './container.js';
|
|
2
|
+
|
|
3
|
+
const t = <T>(name: string): Token<T> => Symbol(name) as Token<T>;
|
|
4
|
+
|
|
5
|
+
export const TOKENS = {
|
|
6
|
+
Logger: t<unknown>('Logger'),
|
|
7
|
+
TokenCounter: t<unknown>('TokenCounter'),
|
|
8
|
+
SessionStore: t<unknown>('SessionStore'),
|
|
9
|
+
ConfigStore: t<unknown>('ConfigStore'),
|
|
10
|
+
ConfigLoader: t<unknown>('ConfigLoader'),
|
|
11
|
+
PermissionPolicy: t<unknown>('PermissionPolicy'),
|
|
12
|
+
Compactor: t<unknown>('Compactor'),
|
|
13
|
+
PathResolver: t<unknown>('PathResolver'),
|
|
14
|
+
Renderer: t<unknown>('Renderer'),
|
|
15
|
+
InputReader: t<unknown>('InputReader'),
|
|
16
|
+
ErrorHandler: t<unknown>('ErrorHandler'),
|
|
17
|
+
RetryPolicy: t<unknown>('RetryPolicy'),
|
|
18
|
+
SystemPromptBuilder: t<unknown>('SystemPromptBuilder'),
|
|
19
|
+
SecretScrubber: t<unknown>('SecretScrubber'),
|
|
20
|
+
ProviderRunner: t<unknown>('ProviderRunner'),
|
|
21
|
+
} as const;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DefaultPermissionPolicy,
|
|
3
|
+
matchGlob,
|
|
4
|
+
} from './permission-policy.js';
|
|
5
|
+
export type {
|
|
6
|
+
PermissionSource,
|
|
7
|
+
PermissionDecision,
|
|
8
|
+
TrustRule,
|
|
9
|
+
TrustFile,
|
|
10
|
+
PermissionPolicy,
|
|
11
|
+
ConfirmAwaiter,
|
|
12
|
+
DefaultPermissionPolicyOptions,
|
|
13
|
+
} from './permission-policy.js';
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { promises as fsp } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { EventBus } from '../kernel/events.js';
|
|
4
|
+
import { restrictFilePermissions } from '../session/secret-vault.js';
|
|
5
|
+
import type { Permission, RiskTier, Tool, ToolContext } from '../types/tool.js';
|
|
6
|
+
|
|
7
|
+
export type PermissionSource =
|
|
8
|
+
| 'default'
|
|
9
|
+
| 'trust'
|
|
10
|
+
| 'yolo'
|
|
11
|
+
| 'user'
|
|
12
|
+
| 'deny'
|
|
13
|
+
| 'subagent_guard';
|
|
14
|
+
|
|
15
|
+
export interface PermissionDecision {
|
|
16
|
+
permission: Permission;
|
|
17
|
+
reason?: string | undefined;
|
|
18
|
+
source: PermissionSource;
|
|
19
|
+
riskTier?: RiskTier | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TrustRule {
|
|
23
|
+
tool: string;
|
|
24
|
+
pattern: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TrustFile {
|
|
28
|
+
allow: TrustRule[];
|
|
29
|
+
deny: TrustRule[];
|
|
30
|
+
yolo: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PermissionPolicy {
|
|
34
|
+
evaluate(tool: Tool, input: unknown, ctx?: ToolContext): Promise<PermissionDecision>;
|
|
35
|
+
trust(rule: TrustRule): Promise<void>;
|
|
36
|
+
deny(rule: TrustRule): Promise<void>;
|
|
37
|
+
allowOnce(rule: TrustRule): void;
|
|
38
|
+
denyOnce(rule: TrustRule): void;
|
|
39
|
+
reload(): Promise<void>;
|
|
40
|
+
getYolo(): boolean;
|
|
41
|
+
setYolo(v: boolean): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ConfirmAwaiter = (
|
|
45
|
+
tool: Tool,
|
|
46
|
+
input: unknown,
|
|
47
|
+
toolUseId: string,
|
|
48
|
+
suggestedPattern: string,
|
|
49
|
+
) => Promise<'yes' | 'no' | 'always' | 'deny'>;
|
|
50
|
+
|
|
51
|
+
const DANGEROUS_CAPABILITIES = new Set([
|
|
52
|
+
'shell.arbitrary',
|
|
53
|
+
'fs.write.outside-project',
|
|
54
|
+
'net.outbound',
|
|
55
|
+
'package.install',
|
|
56
|
+
'config.mutate',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const EMPTY_TRUST: TrustFile = { allow: [], deny: [], yolo: false };
|
|
60
|
+
|
|
61
|
+
export interface DefaultPermissionPolicyOptions {
|
|
62
|
+
homeDir: string;
|
|
63
|
+
projectRoot: string;
|
|
64
|
+
events?: EventBus | undefined;
|
|
65
|
+
subagentGuard?: boolean | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class DefaultPermissionPolicy implements PermissionPolicy {
|
|
69
|
+
private homeFile: string;
|
|
70
|
+
private projectFile: string;
|
|
71
|
+
private events?: EventBus | undefined;
|
|
72
|
+
private subagentGuard: boolean;
|
|
73
|
+
private trustFile: TrustFile = { ...EMPTY_TRUST };
|
|
74
|
+
private readonly sessionAllow: Set<string> = new Set();
|
|
75
|
+
private readonly sessionDeny: Set<string> = new Set();
|
|
76
|
+
|
|
77
|
+
constructor(opts: DefaultPermissionPolicyOptions) {
|
|
78
|
+
this.homeFile = path.join(opts.homeDir, 'trust.json');
|
|
79
|
+
this.projectFile = path.join(opts.projectRoot, '.flowcodex', 'trust.json');
|
|
80
|
+
this.events = opts.events;
|
|
81
|
+
this.subagentGuard = opts.subagentGuard ?? false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async load(): Promise<void> {
|
|
85
|
+
this.trustFile = await this.readMerged();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async reload(): Promise<void> {
|
|
89
|
+
this.trustFile = await this.readMerged();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async evaluate(tool: Tool, input: unknown): Promise<PermissionDecision> {
|
|
93
|
+
const subject = extractSubject(tool, input);
|
|
94
|
+
|
|
95
|
+
if (this.matchesSession(this.sessionDeny, tool.name, subject)) {
|
|
96
|
+
return { permission: 'deny', reason: 'denied once this session', source: 'user' };
|
|
97
|
+
}
|
|
98
|
+
if (this.matchesRules(this.trustFile.deny, tool.name, subject)) {
|
|
99
|
+
return { permission: 'deny', reason: 'denied by trust rule', source: 'deny', riskTier: tool.riskTier };
|
|
100
|
+
}
|
|
101
|
+
if (this.matchesSession(this.sessionAllow, tool.name, subject)) {
|
|
102
|
+
return { permission: 'auto', reason: 'allowed once this session', source: 'user' };
|
|
103
|
+
}
|
|
104
|
+
if (this.matchesRules(this.trustFile.allow, tool.name, subject)) {
|
|
105
|
+
return { permission: 'auto', reason: 'allowed by trust rule', source: 'trust', riskTier: tool.riskTier };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let permission: Permission = tool.permission;
|
|
109
|
+
let source: PermissionSource = 'default';
|
|
110
|
+
|
|
111
|
+
if (
|
|
112
|
+
permission === 'auto' &&
|
|
113
|
+
!this.subagentGuard &&
|
|
114
|
+
!this.trustFile.yolo &&
|
|
115
|
+
hasDangerousCapability(tool)
|
|
116
|
+
) {
|
|
117
|
+
permission = 'confirm';
|
|
118
|
+
source = 'default';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (permission === 'confirm' && this.trustFile.yolo) {
|
|
122
|
+
// YOLO skips ALL confirm prompts (including destructive). Explicit deny
|
|
123
|
+
// rules above still block — YOLO never bypasses a user-configured denial.
|
|
124
|
+
permission = 'auto';
|
|
125
|
+
source = 'yolo';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { permission, source, riskTier: tool.riskTier };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async trust(rule: TrustRule): Promise<void> {
|
|
132
|
+
this.trustFile.allow = addRule(this.trustFile.allow, rule);
|
|
133
|
+
await this.writeHome();
|
|
134
|
+
this.emitPersisted('allow', rule, 'trust');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async deny(rule: TrustRule): Promise<void> {
|
|
138
|
+
this.trustFile.deny = addRule(this.trustFile.deny, rule);
|
|
139
|
+
await this.writeHome();
|
|
140
|
+
this.emitPersisted('deny', rule, 'trust');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
allowOnce(rule: TrustRule): void {
|
|
144
|
+
this.sessionAllow.add(ruleKey(rule));
|
|
145
|
+
this.emitPersisted('allow', rule, 'session');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
denyOnce(rule: TrustRule): void {
|
|
149
|
+
this.sessionDeny.add(ruleKey(rule));
|
|
150
|
+
this.emitPersisted('deny', rule, 'session');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
getYolo(): boolean {
|
|
154
|
+
return this.trustFile.yolo;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
setYolo(v: boolean): void {
|
|
158
|
+
this.trustFile.yolo = v;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private emitPersisted(action: 'allow' | 'deny', rule: TrustRule, scope: 'session' | 'trust'): void {
|
|
162
|
+
this.events?.emit('permission.persisted', { action, tool: rule.tool, pattern: rule.pattern, scope });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private matchesSession(set: Set<string>, toolName: string, subject: string): boolean {
|
|
166
|
+
for (const key of set) {
|
|
167
|
+
const sep = key.indexOf('|');
|
|
168
|
+
const t = sep >= 0 ? key.slice(0, sep) : key;
|
|
169
|
+
const p = sep >= 0 ? key.slice(sep + 1) : '*';
|
|
170
|
+
if (matchesRule(t, p, toolName, subject)) return true;
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private matchesRules(rules: readonly TrustRule[], toolName: string, subject: string): boolean {
|
|
176
|
+
return rules.some((r) => matchesRule(r.tool, r.pattern, toolName, subject));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private async readMerged(): Promise<TrustFile> {
|
|
180
|
+
const home = await this.readTrustFile(this.homeFile);
|
|
181
|
+
const project = await this.readTrustFile(this.projectFile);
|
|
182
|
+
// project appends; cannot weaken a home deny (deny always wins by being checked first)
|
|
183
|
+
return {
|
|
184
|
+
allow: [...home.allow, ...project.allow],
|
|
185
|
+
deny: [...home.deny, ...project.deny],
|
|
186
|
+
yolo: home.yolo || project.yolo,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async readTrustFile(file: string): Promise<TrustFile> {
|
|
191
|
+
try {
|
|
192
|
+
const raw = await fsp.readFile(file, 'utf8');
|
|
193
|
+
const parsed = JSON.parse(raw) as Partial<TrustFile>;
|
|
194
|
+
return {
|
|
195
|
+
allow: Array.isArray(parsed.allow) ? parsed.allow : [],
|
|
196
|
+
deny: Array.isArray(parsed.deny) ? parsed.deny : [],
|
|
197
|
+
yolo: parsed.yolo === true,
|
|
198
|
+
};
|
|
199
|
+
} catch {
|
|
200
|
+
return { ...EMPTY_TRUST };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async writeHome(): Promise<void> {
|
|
205
|
+
const dir = path.dirname(this.homeFile);
|
|
206
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
207
|
+
const tmp = `${this.homeFile}.tmp`;
|
|
208
|
+
await fsp.writeFile(tmp, JSON.stringify(this.stripProjectRules(this.trustFile), null, 2), 'utf8');
|
|
209
|
+
await restrictFilePermissions(tmp);
|
|
210
|
+
await fsp.rename(tmp, this.homeFile);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private stripProjectRules(t: TrustFile): TrustFile {
|
|
214
|
+
// home file stores only rules written via trust()/deny() (which always go to home);
|
|
215
|
+
// project rules are read-only and re-merged on load.
|
|
216
|
+
return t;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function extractSubject(tool: Tool, input: unknown): string {
|
|
221
|
+
if (!tool.subjectKey) return '';
|
|
222
|
+
const v = (input as Record<string, unknown> | null)?.[tool.subjectKey];
|
|
223
|
+
return typeof v === 'string' ? v : v === undefined ? '' : String(v);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function hasDangerousCapability(tool: Tool): boolean {
|
|
227
|
+
if (!tool.capabilities) return false;
|
|
228
|
+
return tool.capabilities.some((c) => DANGEROUS_CAPABILITIES.has(c));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function ruleKey(rule: TrustRule): string {
|
|
232
|
+
return `${rule.tool}|${rule.pattern}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function addRule(rules: readonly TrustRule[], rule: TrustRule): TrustRule[] {
|
|
236
|
+
if (rules.some((r) => r.tool === rule.tool && r.pattern === rule.pattern)) {
|
|
237
|
+
return [...rules];
|
|
238
|
+
}
|
|
239
|
+
return [...rules, rule];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function matchesRule(ruleTool: string, rulePattern: string, toolName: string, subject: string): boolean {
|
|
243
|
+
const toolOk = ruleTool === '*' || ruleTool === toolName;
|
|
244
|
+
if (!toolOk) return false;
|
|
245
|
+
if (rulePattern === '*') return true;
|
|
246
|
+
if (!subject) return false;
|
|
247
|
+
return matchGlob(rulePattern, subject);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function matchGlob(pattern: string, subject: string): boolean {
|
|
251
|
+
const re = globToRegExp(pattern);
|
|
252
|
+
return re.test(subject);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function globToRegExp(pattern: string): RegExp {
|
|
256
|
+
let out = '^';
|
|
257
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
258
|
+
const ch = pattern[i]!;
|
|
259
|
+
if (ch === '*' && pattern[i + 1] === '*') {
|
|
260
|
+
out += '.*';
|
|
261
|
+
i++;
|
|
262
|
+
} else if (ch === '*') {
|
|
263
|
+
out += '.*';
|
|
264
|
+
} else if (ch === '?') {
|
|
265
|
+
out += '.';
|
|
266
|
+
} else if (/[+^$.(){}|[\]\\]/.test(ch)) {
|
|
267
|
+
out += `\\${ch}`;
|
|
268
|
+
} else {
|
|
269
|
+
out += ch;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return new RegExp(`${out}$`, 's');
|
|
273
|
+
}
|